fix(docs): improve docs slash menu and shortcuts (#7072)

This commit is contained in:
Univer
2026-06-12 19:03:37 +08:00
committed by GitHub
parent d3051d070f
commit 68f1d19b45
60 changed files with 3133 additions and 352 deletions
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { DOC_CONTENT_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { DOC_CONTENT_INSERT_MENU_ID, DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID, DOC_PARAGRAPH_T_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { InsertDocImageCommand } from '../../commands/commands/insert-image.command';
@@ -33,6 +33,8 @@ describe('docs drawing menu schema', () => {
expect(paragraph[ContextMenuGroup.LAYOUT][INSERT_BELLOW_MENU_ID][InsertDocImageCommand.id].menuItemFactory).toBeDefined();
expect(paragraph[EMPTY_PARAGRAPH_MENU_ID][ContextMenuGroup.LAYOUT][InsertDocImageCommand.id].menuItemFactory).toBeDefined();
expect(paragraph[DOC_CONTENT_INSERT_MENU_ID][ContextMenuGroup.LAYOUT][InsertDocImageCommand.id].menuItemFactory).toBeDefined();
expect(paragraph[DOC_PARAGRAPH_T_INSERT_MENU_ID].insert[InsertDocImageCommand.id].menuItemFactory).toBeDefined();
expect(paragraph[DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID].insert[`${InsertDocImageCommand.id}.below`].menuItemFactory).toBeDefined();
});
it('uses the same image icon in paragraph insert menus', () => {
@@ -46,6 +48,8 @@ describe('docs drawing menu schema', () => {
const rootShapeMenu = paragraph[DOCS_SHAPE_MENU_ID].shapes;
const belowShapeMenu = paragraph[DOCS_SHAPE_BELOW_MENU_ID].shapes;
expect(paragraph[DOC_PARAGRAPH_T_INSERT_MENU_ID].insert[DOCS_SHAPE_MENU_ID].menuItemFactory).toBeDefined();
expect(paragraph[DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID].insert[DOCS_SHAPE_BELOW_MENU_ID].menuItemFactory).toBeDefined();
expect(rootShapeMenu[InsertDocRectangleShapeCommand.id].menuItemFactory).toBeDefined();
expect(rootShapeMenu[InsertDocEllipseShapeCommand.id].menuItemFactory).toBeDefined();
expect(belowShapeMenu[`${InsertDocRectangleShapeCommand.id}.below`].menuItemFactory).toBeDefined();
@@ -81,3 +81,17 @@ export function UploadFloatImageMenuFactory(_accessor: IAccessor): IMenuItem {
hidden$: getMenuHiddenObservable(_accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function UploadFloatImageBelowMenuFactory(_accessor: IAccessor): IMenuItem {
return {
id: `${IMAGE_MENU_UPLOAD_FLOAT_ID}.below`,
commandId: IMAGE_MENU_UPLOAD_FLOAT_ID,
title: 'docs-drawing-ui.upload.float',
type: MenuItemType.BUTTON,
icon: 'AddImageIcon',
params: {
paragraphMenuPlacement: 'below',
},
hidden$: getMenuHiddenObservable(_accessor, UniverInstanceType.UNIVER_DOC),
};
}
+28 -1
View File
@@ -15,13 +15,14 @@
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { DOC_CONTENT_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { DOC_CONTENT_INSERT_MENU_ID, DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID, DOC_PARAGRAPH_T_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../commands/commands/insert-shape.command';
import {
DOCS_IMAGE_MENU_ID,
IMAGE_MENU_UPLOAD_FLOAT_ID,
ImageMenuFactory,
UploadFloatImageBelowMenuFactory,
UploadFloatImageMenuFactory,
} from './image.menu';
import {
@@ -31,6 +32,8 @@ import {
InsertEllipseShapeMenuFactory,
InsertRectangleShapeBelowMenuFactory,
InsertRectangleShapeMenuFactory,
ShapeBelowMenuFactory,
ShapeMenuFactory,
} from './shape.menu';
export const menuSchema: MenuSchemaType = {
@@ -69,6 +72,30 @@ export const menuSchema: MenuSchemaType = {
},
},
},
[DOC_PARAGRAPH_T_INSERT_MENU_ID]: {
insert: {
[IMAGE_MENU_UPLOAD_FLOAT_ID]: {
order: 1,
menuItemFactory: UploadFloatImageMenuFactory,
},
[DOCS_SHAPE_MENU_ID]: {
order: 2,
menuItemFactory: ShapeMenuFactory,
},
},
},
[DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID]: {
insert: {
[`${IMAGE_MENU_UPLOAD_FLOAT_ID}.below`]: {
order: 1,
menuItemFactory: UploadFloatImageBelowMenuFactory,
},
[DOCS_SHAPE_BELOW_MENU_ID]: {
order: 2,
menuItemFactory: ShapeBelowMenuFactory,
},
},
},
[DOCS_SHAPE_MENU_ID]: {
shapes: {
order: 0,
@@ -34,6 +34,17 @@ export function ShapeMenuFactory(accessor: IAccessor): IMenuItem {
};
}
export function ShapeBelowMenuFactory(accessor: IAccessor): IMenuItem {
return {
id: DOCS_SHAPE_BELOW_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'ShapeIcon',
title: 'Insert Shape',
tooltip: 'Insert Shape',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function InsertRectangleShapeMenuFactory(accessor: IAccessor): IMenuButtonItem {
return {
id: InsertDocRectangleShapeCommand.id,
@@ -17,8 +17,11 @@
import type { Direction, IOperation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
export type DocCursorMoveGranularity = 'character' | 'word' | 'line' | 'document';
export interface IMoveCursorOperationParams {
direction: Direction;
granularity?: DocCursorMoveGranularity;
}
// TODO@wzhudev: it should be moved to a command then trigger the operation.
@@ -21,7 +21,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as paragraphMenu from '..';
import { createParagraphMenuHoverOpenScheduler, getParagraphFormattingRange, getParagraphMenuActiveHeadingCommandId, getParagraphMenuCommand, getParagraphMenuCommandTargetRange, getParagraphMenuHiddenHeadingCommandIds, getParagraphMenuHiddenItemIds, getParagraphMenuIconSizeClass, getParagraphMenuPopupDirection, getParagraphMenuResolvedCommand, getParagraphMenuTargetRange, isEmptyParagraphMenuTarget, PARAGRAPH_MENU_HOVER_OPEN_DELAY, setParagraphMenuInteractionActive, shouldShowParagraphSettingMenu, shouldUseInsertBelowRange } from '..';
import { createParagraphMenuHoverOpenScheduler, finishParagraphMenuCommand, getParagraphFormattingRange, getParagraphMenuActiveHeadingCommandId, getParagraphMenuCommand, getParagraphMenuCommandTargetRange, getParagraphMenuHiddenHeadingCommandIds, getParagraphMenuHiddenItemIds, getParagraphMenuIconSizeClass, getParagraphMenuPopupDirection, getParagraphMenuResolvedCommand, getParagraphMenuTargetRange, isEmptyParagraphMenuTarget, PARAGRAPH_MENU_HOVER_OPEN_DELAY, setParagraphMenuInteractionActive, shouldShowParagraphSettingMenu, shouldUseInsertBelowRange } from '..';
import { HorizontalLineCommand } from '../../../commands/commands/doc-horizontal-line.command';
import { SetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextColorCommand } from '../../../commands/commands/inline-format.command';
import { BulletListCommand, InsertBulletListBellowCommand, OrderListCommand } from '../../../commands/commands/list.command';
@@ -513,4 +513,20 @@ describe('ParagraphMenu', () => {
id: INSERT_BELLOW_MENU_ID,
})).toBe(true);
});
it('releases the paragraph menu service before returning focus after command execution', () => {
const calls: string[] = [];
finishParagraphMenuCommand(
{
hideParagraphMenu: vi.fn(() => calls.push('service')),
} as never,
{
focus: vi.fn(() => calls.push('focus')),
} as never,
() => calls.push('ui')
);
expect(calls).toEqual(['service', 'ui', 'focus']);
});
});
@@ -16,11 +16,11 @@
import type { DocumentDataModel, IDocumentBlockRange, IDocumentBody, IParagraph, ITextRun } from '@univerjs/core';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IPopup, IValueOption } from '@univerjs/ui';
import type { IPopup, IValueOption, RectPopupDirection } from '@univerjs/ui';
import type { CSSProperties } from 'react';
import type { IMutiPageParagraphBound } from '../../services/doc-event-manager.service';
import type { IDocBlockMenuTarget } from '../../services/doc-paragraph-menu.service';
import { DataStreamTreeTokenType, ICommandService, IUniverInstanceService, JSONX, NamedStyleType, SliceBodyType, Tools, UniverInstanceType } from '@univerjs/core';
import { DataStreamTreeTokenType, DocumentBlockRangeType, ICommandService, IUniverInstanceService, JSONX, NamedStyleType, SliceBodyType, Tools, UniverInstanceType } from '@univerjs/core';
import { clsx } from '@univerjs/design';
import { DocContentInsertService, DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
@@ -47,13 +47,8 @@ import {
DOC_PARAGRAPH_T_INSERT_MENU_ID,
DOC_PARAGRAPH_T_RESET_COLORS_ID,
DOC_TABLE_BLOCK_MENU_ID,
DOCS_CALLOUT_INSERT_COMMAND_ID,
DOCS_CODE_INSERT_COMMAND_ID,
DOCS_QUOTE_INSERT_COMMAND_ID,
HEADING_ICON_MAP,
INSERT_BELLOW_MENU_ID,
INSERT_DOC_IMAGE_COMMAND_ID,
INSERT_DOC_SHAPE_COMMAND_ID,
} from '../../menu/paragraph-menu';
import { IDocClipboardService } from '../../services/clipboard/clipboard.service';
import { DocEventManagerService } from '../../services/doc-event-manager.service';
@@ -75,6 +70,7 @@ export const PARAGRAPH_MENU_HOVER_OPEN_DELAY = 800;
const PARAGRAPH_MENU_HOVER_HIDE_DELAY = 240;
const PARAGRAPH_MENU_HOVER_BRIDGE_EDGE_OVERLAP = 12;
const PARAGRAPH_MENU_HOVER_BRIDGE_VERTICAL_PADDING = 8;
type ParagraphMenuOpenMode = 'pointer' | 'slash';
export function createParagraphMenuHoverOpenScheduler(openMenu: () => void, delay = PARAGRAPH_MENU_HOVER_OPEN_DELAY) {
let openTimer: number | null = null;
@@ -206,11 +202,19 @@ const LIST_ICON_TO_COMMAND_ID: Record<string, string> = {
TodoListDoubleIcon: CheckListCommand.id,
};
const BLOCK_TYPE_TO_COMMAND_ID: Record<string, string> = {
code: DOCS_CODE_INSERT_COMMAND_ID,
quote: DOCS_QUOTE_INSERT_COMMAND_ID,
callout: DOCS_CALLOUT_INSERT_COMMAND_ID,
};
const PARAGRAPH_MENU_BLOCK_RANGE_TYPES = [
DocumentBlockRangeType.CODE,
DocumentBlockRangeType.QUOTE,
DocumentBlockRangeType.CALLOUT,
];
function getParagraphMenuBlockRangeCommandId(blockType?: string): string | undefined {
return blockType ? `docs-${blockType}.command.insert` : undefined;
}
const PARAGRAPH_MENU_BLOCK_RANGE_COMMAND_IDS = new Set(
PARAGRAPH_MENU_BLOCK_RANGE_TYPES.map((blockType) => getParagraphMenuBlockRangeCommandId(blockType)!)
);
const PARAGRAPH_MENU_SELECTION_COMMAND_IDS = new Set([
BulletListCommand.id,
@@ -219,9 +223,7 @@ const PARAGRAPH_MENU_SELECTION_COMMAND_IDS = new Set([
HorizontalLineCommand.id,
SetInlineFormatTextColorCommand.id,
SetInlineFormatTextBackgroundColorCommand.id,
DOCS_CODE_INSERT_COMMAND_ID,
DOCS_QUOTE_INSERT_COMMAND_ID,
DOCS_CALLOUT_INSERT_COMMAND_ID,
...PARAGRAPH_MENU_BLOCK_RANGE_COMMAND_IDS,
AlignLeftCommand.id,
AlignCenterCommand.id,
AlignRightCommand.id,
@@ -232,8 +234,6 @@ const PARAGRAPH_MENU_SKIP_REPLACE_SELECTION_COMMAND_IDS = new Set([
DocCopyCurrentParagraphCommand.id,
DocCutCurrentParagraphCommand.id,
DeleteCurrentParagraphCommand.id,
INSERT_DOC_IMAGE_COMMAND_ID,
INSERT_DOC_SHAPE_COMMAND_ID,
]);
export function getParagraphMenuCommand(params: IValueOption, targetRange?: ITextRangeWithStyle | null): { commandId?: string; params?: object } {
@@ -314,8 +314,9 @@ export function shouldShowParagraphSettingMenu(target: IDocBlockMenuTarget | nul
function getParagraphMenuActiveItemIds(target: IDocBlockMenuTarget | null | undefined, namedStyleType?: NamedStyleType): string[] {
if (target?.kind === 'blockRange') {
const blockType = target.blockRange?.blockType;
return blockType && BLOCK_TYPE_TO_COMMAND_ID[blockType]
? [BLOCK_TYPE_TO_COMMAND_ID[blockType]]
const commandId = getParagraphMenuBlockRangeCommandId(blockType);
return commandId && PARAGRAPH_MENU_BLOCK_RANGE_COMMAND_IDS.has(commandId)
? [commandId]
: [];
}
@@ -340,10 +341,13 @@ export function getParagraphMenuHiddenItemIds(
const hiddenIds = [...getParagraphMenuHiddenHeadingCommandIds(namedStyleType)];
const blockType = target?.kind === 'blockRange' ? target.blockRange?.blockType : undefined;
if (blockType === 'callout') {
hiddenIds.push(DOCS_CODE_INSERT_COMMAND_ID, DOCS_QUOTE_INSERT_COMMAND_ID);
} else if (blockType === 'quote' || blockType === 'code') {
hiddenIds.push(DOCS_CALLOUT_INSERT_COMMAND_ID);
if (blockType === DocumentBlockRangeType.CALLOUT) {
hiddenIds.push(
getParagraphMenuBlockRangeCommandId(DocumentBlockRangeType.CODE)!,
getParagraphMenuBlockRangeCommandId(DocumentBlockRangeType.QUOTE)!
);
} else if (blockType === DocumentBlockRangeType.QUOTE || blockType === DocumentBlockRangeType.CODE) {
hiddenIds.push(getParagraphMenuBlockRangeCommandId(DocumentBlockRangeType.CALLOUT)!);
}
return hiddenIds;
@@ -556,6 +560,16 @@ export function getParagraphMenuCommandTargetRange(
return targetRange ?? formattingRange;
}
export function finishParagraphMenuCommand(
docParagraphMenuService: Pick<DocParagraphMenuService, 'hideParagraphMenu'> | null | undefined,
layoutService: Pick<ILayoutService, 'focus'>,
hideMenu: () => void
) {
docParagraphMenuService?.hideParagraphMenu(true);
hideMenu();
layoutService.focus();
}
function getBlockSelectionRange(target: IDocBlockMenuTarget | null | undefined, paragraph?: IMutiPageParagraphBound | null): ITextRangeWithStyle | null {
if (target?.kind !== 'blockRange') {
return getTargetSelectionRange(target, paragraph);
@@ -576,9 +590,10 @@ function getBlockSelectionRange(target: IDocBlockMenuTarget | null | undefined,
export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
const [visible, setVisible] = useState(false);
const [openMode, setOpenMode] = useState<ParagraphMenuOpenMode>('pointer');
const [anchorRect, setAnchorRect] = useState<{ left: number; right: number; top: number; bottom: number } | null>(null);
const [dropRect, setDropRect] = useState<{ left: number; right: number; top: number; bottom: number } | null>(null);
const [menuDirection, setMenuDirection] = useState<'left' | 'right'>('left');
const [menuDirection, setMenuDirection] = useState<RectPopupDirection>('left');
const targetRangeRef = useRef<ITextRangeWithStyle | null>(null);
const dragTargetOffsetRef = useRef<number | null>(null);
const dragRangeRef = useRef<{ startOffset: number; endOffset: number } | null>(null);
@@ -595,6 +610,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
const anchorRef = useRef<HTMLDivElement>(null);
const isMouseOver = useRef(false);
const hideTimerRef = useRef<number | null>(null);
const handledSlashRequestNonceRef = useRef(0);
const renderManagerService = useDependency(IRenderManagerService);
const univerInstanceService = useDependency(IUniverInstanceService);
const renderUnit = renderManagerService.getRenderById(popup.unitId);
@@ -602,6 +618,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
const docParagraphMenuService = renderUnit?.with(DocParagraphMenuService);
const docEventManagerService = renderUnit?.with(DocEventManagerService);
const activeTarget = useObservable(docParagraphMenuService?.activeTarget$);
const slashMenuRequest = useObservable(docParagraphMenuService?.slashMenuRequest$);
const paragraph = useObservable(docEventManagerService?.hoverParagraph$);
const paragraphLeft = useObservable(docEventManagerService?.hoverParagraphLeft$);
const currentActiveTarget = activeTarget ?? docParagraphMenuService?.activeTarget;
@@ -651,6 +668,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
const handleHideMenu = () => {
setVisible(false);
setOpenMode('pointer');
targetRangeRef.current = null;
setParagraphMenuInteractionActive(docParagraphMenuService, false);
};
@@ -671,7 +689,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
}, PARAGRAPH_MENU_HOVER_HIDE_DELAY);
};
const handleOpenMenu = () => {
const handleOpenMenu = (mode: ParagraphMenuOpenMode = 'pointer') => {
clearHideTimer();
const latestTarget = docParagraphMenuService?.activeTarget ?? activeTarget;
setParagraphMenuInteractionActive(docParagraphMenuService, true);
@@ -683,10 +701,24 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
: getParagraphMenuTargetRange(activeParagraphBound);
targetRangeRef.current = targetRange;
updateAnchorRect();
setOpenMode(mode);
setVisible(true);
};
openMenuRef.current = handleOpenMenu;
const handleOpenSlashMenu = (request: NonNullable<typeof slashMenuRequest>) => {
clearHideTimer();
targetRangeRef.current = {
...request.target.menuRange,
segmentId: request.target.paragraph?.segmentId ?? activeParagraphBound?.segmentId,
};
setMenuDirection('vertical');
setAnchorRect(request.anchorRect);
anchorRect$.next(request.anchorRect);
setOpenMode('slash');
setVisible(true);
};
const scheduleOpenMenu = () => {
clearHideTimer();
hoverOpenSchedulerRef.current.schedule();
@@ -765,8 +797,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
}
await commandService.executeCommand(SetInlineFormatTextColorCommand.id, { value: '#000000' });
await commandService.executeCommand(ResetInlineFormatTextBackgroundColorCommand.id);
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
return;
}
@@ -783,8 +814,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
},
},
});
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
return;
}
@@ -807,18 +837,16 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
if (wrappedCommandId) {
await commandService.executeCommand(wrappedCommandId);
}
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
return;
}
if (latestTarget?.kind === 'blockRange') {
const currentBlockCommandId = BLOCK_TYPE_TO_COMMAND_ID[latestTarget.blockRange?.blockType ?? ''];
const currentBlockCommandId = getParagraphMenuBlockRangeCommandId(latestTarget.blockRange?.blockType);
if (commandId === currentBlockCommandId) {
await unwrapActiveBlockRange();
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
return;
}
@@ -837,16 +865,11 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
params: commandParams as Record<string, unknown> | undefined,
}, nextRange);
}
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
return;
}
if (
commandId === DOCS_CODE_INSERT_COMMAND_ID ||
commandId === DOCS_QUOTE_INSERT_COMMAND_ID ||
commandId === DOCS_CALLOUT_INSERT_COMMAND_ID
) {
if (PARAGRAPH_MENU_BLOCK_RANGE_COMMAND_IDS.has(commandId)) {
if (blockRange) {
replaceSelection(blockRange);
}
@@ -855,8 +878,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
commandId,
params: commandParams as Record<string, unknown> | undefined,
}, blockRange);
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
return;
}
}
@@ -868,8 +890,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
await executeResolvedCommand({
id: NormalTextHeadingCommand.id,
}, targetRange);
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
return;
}
@@ -889,11 +910,13 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
commandId,
params: commandParams as Record<string, unknown> | undefined,
}, getParagraphMenuCommandTargetRange(commandId, targetRange, formattingRange));
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
};
const hoverBridgeStyle = visible ? getParagraphMenuHoverBridgeStyle(anchorRect, menuDirection) : undefined;
const hoverBridgeStyle = visible && (menuDirection === 'left' || menuDirection === 'right')
? getParagraphMenuHoverBridgeStyle(anchorRect, menuDirection)
: undefined;
const resolvedParagraphMenuType = openMode === 'slash' ? DOC_PARAGRAPH_T_INSERT_MENU_ID : paragraphMenuType;
useEffect(() => () => {
if (hideTimerRef.current != null) {
@@ -904,6 +927,18 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
setParagraphMenuInteractionActive(docParagraphMenuService, false);
}, []);
useEffect(() => {
if (!slashMenuRequest || handledSlashRequestNonceRef.current >= slashMenuRequest.nonce) {
return;
}
handledSlashRequestNonceRef.current = slashMenuRequest.nonce;
hoverOpenSchedulerRef.current.cancel();
clearHideTimer();
isMouseOver.current = false;
handleOpenSlashMenu(slashMenuRequest);
}, [slashMenuRequest]);
return (
<>
<div
@@ -1078,10 +1113,16 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
>
<ContextMenuPanel
className="univer-w-[212px]"
menuType={paragraphMenuType}
menuType={resolvedParagraphMenuType}
sizeVariant={getParagraphMenuContextMenuSizeVariant()}
activeItemIds={currentActiveTarget?.kind === 'table' ? undefined : paragraphMenuActiveItemIds}
hiddenItemIds={currentActiveTarget?.kind === 'table' ? undefined : paragraphMenuHiddenItemIds}
hiddenItemIds={openMode === 'slash' || currentActiveTarget?.kind === 'table' ? undefined : paragraphMenuHiddenItemIds}
autoFocus={openMode === 'slash'}
autoFocusTarget={openMode === 'slash' ? 'container' : undefined}
suppressHoverUntilPointerMove={openMode === 'slash'}
onCancel={() => {
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
}}
onOptionSelect={async (params) => {
const targetRange = targetRangeRef.current ?? getParagraphMenuTargetRange(activeParagraphBound);
const { commandId, params: commandParams } = getParagraphMenuCommand(params, targetRange);
@@ -1102,8 +1143,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
if (commandId === DocCopyCommand.id || commandId === DocCopyCommand.name) {
await docClipboardService.copy(SliceBodyType.copy, [tableRange]);
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
return;
}
@@ -1111,8 +1151,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
docSelectionManagerService.replaceTextRanges([afterTableRange], false);
const clipboardItems = await clipboardInterfaceService.read();
await docClipboardService.paste(clipboardItems);
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
return;
}
@@ -1126,8 +1165,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
commandService.executeCommand(commandId, commandParams);
}
layoutService.focus();
handleHideMenu();
finishParagraphMenuCommand(docParagraphMenuService, layoutService, handleHideMenu);
return;
}
+2
View File
@@ -26,10 +26,12 @@ export interface IUniverDocsUIConfig {
container?: HTMLElement | string;
toc?: boolean;
footer?: boolean;
placeholder?: boolean;
override?: DependencyOverride;
}
export const defaultPluginConfig: IUniverDocsUIConfig = {
toc: false,
footer: true,
placeholder: true,
};
@@ -0,0 +1,126 @@
/**
* 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 { DataStreamTreeTokenType, Direction } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { DocMoveCursorController } from '../doc-move-cursor.controller';
function createControllerHarness() {
return Object.create(DocMoveCursorController.prototype) as Record<string, (...args: unknown[]) => unknown>;
}
describe('DocMoveCursorController movement helpers', () => {
it('resolves Chinese word boundaries with the shared Segmenter behavior', () => {
const controller = createControllerHarness();
const line = { paragraphIndex: 0, st: 10, divides: [] as unknown[], parent: null as unknown };
const column = { lines: [line] };
line.parent = column;
const glyphs = ['中', '文', '测', '试'].map((content) => ({
count: 1,
content,
streamType: DataStreamTreeTokenType.LETTER,
}));
const divide = { st: 10, glyphGroup: glyphs, parent: line };
line.divides = [divide];
glyphs.forEach((glyph) => {
Object.assign(glyph, { parent: divide });
});
const skeleton = {
findNodeByCharIndex: vi.fn(() => glyphs[1]),
};
expect(controller._getWordBoundaryOffset(skeleton, 11, Direction.RIGHT, '', -1, 100)).toBe(12);
expect(controller._getWordBoundaryOffset(skeleton, 13, Direction.LEFT, '', -1, 100)).toBe(12);
});
it('resolves visual line start and end from skeleton glyph positions', () => {
const controller = createControllerHarness();
const firstGlyph = {
count: 1,
content: 'A',
streamType: DataStreamTreeTokenType.LETTER,
};
const paragraphGlyph = {
count: 1,
content: '\r',
streamType: DataStreamTreeTokenType.PARAGRAPH,
};
const lastGlyph = {
count: 1,
content: 'B',
streamType: DataStreamTreeTokenType.LETTER,
};
const line = { divides: [] as unknown[] };
const divide = { st: 5, glyphGroup: [firstGlyph, lastGlyph, paragraphGlyph], parent: line };
line.divides = [divide];
[firstGlyph, lastGlyph, paragraphGlyph].forEach((glyph) => {
Object.assign(glyph, { parent: divide });
});
const skeleton = {
findNodeByCharIndex: vi.fn(() => lastGlyph),
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === firstGlyph ? 0 : 1 })),
findCharIndexByPosition: vi.fn((position) => position.isBack ? 5 : 7),
};
expect(controller._getLineBoundaryOffset(skeleton, 6, Direction.LEFT, '', -1, 100)).toBe(5);
expect(controller._getLineBoundaryOffset(skeleton, 6, Direction.RIGHT, '', -1, 100)).toBe(7);
});
it('ignores block range boundary glyphs when matching vertical cursor position', () => {
const controller = createControllerHarness();
const firstGlyph = {
count: 1,
content: 'A',
left: 0,
streamType: DataStreamTreeTokenType.LETTER,
};
const lastTextGlyph = {
count: 1,
content: 'B',
left: 20,
streamType: DataStreamTreeTokenType.LETTER,
};
const blockEndGlyph = {
count: 1,
content: DataStreamTreeTokenType.BLOCK_END,
left: 88,
streamType: DataStreamTreeTokenType.BLOCK_END,
};
const line = { divides: [] as unknown[] };
const divide = { left: 0, glyphGroup: [firstGlyph, lastTextGlyph, blockEndGlyph], parent: line };
line.divides = [divide];
[firstGlyph, lastTextGlyph, blockEndGlyph].forEach((glyph) => {
Object.assign(glyph, { parent: divide });
});
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === lastTextGlyph ? 1 : -1 })),
};
expect(controller._matchPositionByLeftOffset(skeleton, line, 90, { segmentPage: -1 })).toEqual({ glyph: 1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(lastTextGlyph, -1);
});
it('resolves document start and end offsets', () => {
const controller = createControllerHarness();
expect(controller._getCursorOffsetByGranularity({}, 8, Direction.UP, 'document', '', -1, 20)).toBe(0);
expect(controller._getCursorOffsetByGranularity({}, 8, Direction.DOWN, 'document', '', -1, 20)).toBe(18);
});
});
@@ -26,7 +26,7 @@ import type {
INodeSearch,
} from '@univerjs/engine-render';
import type { Subscription } from 'rxjs';
import type { IMoveCursorOperationParams } from '../commands/operations/doc-cursor.operation';
import type { DocCursorMoveGranularity, IMoveCursorOperationParams } from '../commands/operations/doc-cursor.operation';
import {
DataStreamTreeTokenType,
Direction,
@@ -43,8 +43,16 @@ import { getDocObject } from '../basics/component-tools';
import { findAboveCell, findBellowCell, findLineBeforeAndAfterTable, findTableAfterLine, findTableBeforeLine, firstLineInCell, firstLineInTable, lastLineInCell, lastLineInTable } from '../basics/table';
import { MoveCursorOperation, MoveSelectionOperation } from '../commands/operations/doc-cursor.operation';
import { NodePositionConvertToCursor } from '../services/selection/convert-text-range';
import { getParagraphInfoByGlyph } from '../services/selection/selection-utils';
import { getNextWordBoundaryOffset } from '../services/selection/word-boundary';
import { DocBackScrollRenderController } from './render-controllers/back-scroll.render-controller';
interface IWholeEntityRange {
wholeEntity?: boolean;
startIndex: number;
endIndex: number;
}
export class DocMoveCursorController extends Disposable {
private _onInputSubscription: Nullable<Subscription>;
@@ -78,11 +86,11 @@ export class DocMoveCursorController extends Disposable {
switch (command.id) {
case MoveCursorOperation.id: {
return this._handleMoveCursor(param.direction);
return this._handleMoveCursor(param.direction, param.granularity ?? 'character');
}
case MoveSelectionOperation.id: {
return this._handleShiftMoveSelection(param.direction);
return this._handleShiftMoveSelection(param.direction, param.granularity ?? 'character');
}
default: {
@@ -94,7 +102,7 @@ export class DocMoveCursorController extends Disposable {
}
// eslint-disable-next-line max-lines-per-function, complexity
private _handleShiftMoveSelection(direction: Direction) {
private _handleShiftMoveSelection(direction: Direction, granularity: DocCursorMoveGranularity = 'character') {
const activeRange = this._textSelectionManagerService.getActiveTextRange();
const allRanges = this._textSelectionManagerService.getTextRanges()!;
const docDataModel = this._univerInstanceService.getCurrentUniverDocInstance();
@@ -122,6 +130,8 @@ export class DocMoveCursorController extends Disposable {
endNodePosition,
segmentPage,
} = activeRange;
const normalizedSegmentId = segmentId ?? '';
const normalizedSegmentPage = segmentPage ?? -1;
if (allRanges.length > 1) {
let min = Number.POSITIVE_INFINITY;
@@ -154,7 +164,35 @@ export class DocMoveCursorController extends Disposable {
: rangeDirection === RANGE_DIRECTION.FORWARD
? endOffset
: startOffset;
const dataStreamLength = docDataModel.getSelfOrHeaderFooterModel(segmentId).getBody()!.dataStream.length ?? Number.POSITIVE_INFINITY;
const dataStreamLength = docDataModel.getSelfOrHeaderFooterModel(normalizedSegmentId).getBody()!.dataStream.length ?? Number.POSITIVE_INFINITY;
if (granularity !== 'character') {
const nextOffset = this._getCursorOffsetByGranularity(
skeleton,
focusOffset,
direction,
granularity,
normalizedSegmentId,
normalizedSegmentPage,
dataStreamLength
);
if (nextOffset == null || nextOffset === focusOffset) {
return;
}
this._textSelectionManagerService.replaceTextRanges([
{
startOffset: anchorOffset,
endOffset: nextOffset,
style,
},
], false);
this._scrollToFocusNodePosition(docDataModel.getUnitId(), nextOffset);
return;
}
if (direction === Direction.LEFT || direction === Direction.RIGHT) {
const preGlyph = skeleton.findNodeByCharIndex(focusOffset - 1, segmentId, segmentPage);
@@ -219,7 +257,7 @@ export class DocMoveCursorController extends Disposable {
}
// eslint-disable-next-line max-lines-per-function, complexity
private _handleMoveCursor(direction: Direction) {
private _handleMoveCursor(direction: Direction, granularity: DocCursorMoveGranularity = 'character') {
const activeRange = this._textSelectionManagerService.getActiveTextRange();
const allRanges = this._textSelectionManagerService.getTextRanges();
const docDataModel = this._univerInstanceService.getCurrentUniverDocInstance();
@@ -236,7 +274,9 @@ export class DocMoveCursorController extends Disposable {
}
const { startOffset, endOffset, style, collapsed, segmentId, startNodePosition, endNodePosition, segmentPage } = activeRange;
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId).getBody();
const normalizedSegmentId = segmentId ?? '';
const normalizedSegmentPage = segmentPage ?? -1;
const body = docDataModel.getSelfOrHeaderFooterModel(normalizedSegmentId).getBody();
if (body == null) {
return;
@@ -245,6 +285,52 @@ export class DocMoveCursorController extends Disposable {
const dataStreamLength = body.dataStream.length ?? Number.POSITIVE_INFINITY;
const customRanges = docDataModel.getCustomRanges() ?? [];
if (granularity !== 'character') {
let cursorOffset: number;
if (!activeRange.collapsed || allRanges.length > 1) {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const range of allRanges) {
min = Math.min(min, range.startOffset!);
max = Math.max(max, range.endOffset!);
}
cursorOffset = direction === Direction.LEFT || direction === Direction.UP ? min : max;
} else {
cursorOffset = direction === Direction.LEFT || direction === Direction.UP ? startOffset : endOffset;
}
let cursor = this._getCursorOffsetByGranularity(
skeleton,
cursorOffset,
direction,
granularity,
normalizedSegmentId,
normalizedSegmentPage,
dataStreamLength
);
if (cursor == null) {
return;
}
cursor = this._normalizeCursorOffset(body.dataStream, customRanges, cursor, direction);
this._textSelectionManagerService.replaceTextRanges([
{
startOffset: cursor,
endOffset: cursor,
style,
},
], false);
this._scrollToFocusNodePosition(docDataModel.getUnitId(), cursor);
return;
}
if (direction === Direction.LEFT || direction === Direction.RIGHT) {
let cursor: number;
@@ -271,13 +357,7 @@ export class DocMoveCursorController extends Disposable {
}
}
const skipTokens: string[] = [
DataStreamTreeTokenType.TABLE_START,
DataStreamTreeTokenType.TABLE_END,
DataStreamTreeTokenType.TABLE_ROW_START,
DataStreamTreeTokenType.TABLE_ROW_END,
DataStreamTreeTokenType.TABLE_CELL_START,
DataStreamTreeTokenType.TABLE_CELL_END,
DataStreamTreeTokenType.SECTION_BREAK,
...this._getCursorSkipTokens(),
];
if (direction === Direction.LEFT) {
while (skipTokens.includes(body.dataStream[cursor])) {
@@ -289,14 +369,7 @@ export class DocMoveCursorController extends Disposable {
}
}
const relativeRanges = customRanges.filter((range) => range.wholeEntity && range.startIndex < cursor && range.endIndex >= cursor);
relativeRanges.forEach((range) => {
if (direction === Direction.LEFT) {
cursor = Math.min(range.startIndex, cursor);
} else {
cursor = Math.max(range.endIndex + 1, cursor);
}
});
cursor = this._normalizeWholeEntityRanges(customRanges, cursor, direction);
this._textSelectionManagerService.replaceTextRanges([
{
@@ -361,6 +434,192 @@ export class DocMoveCursorController extends Disposable {
}
}
private _getCursorOffsetByGranularity(
skeleton: DocumentSkeleton,
focusOffset: number,
direction: Direction,
granularity: DocCursorMoveGranularity,
segmentId: string,
segmentPage: number,
dataStreamLength: number
): Nullable<number> {
switch (granularity) {
case 'document':
return direction === Direction.LEFT || direction === Direction.UP ? 0 : dataStreamLength - 2;
case 'line':
return this._getLineBoundaryOffset(skeleton, focusOffset, direction, segmentId, segmentPage, dataStreamLength);
case 'word':
return this._getWordBoundaryOffset(skeleton, focusOffset, direction, segmentId, segmentPage, dataStreamLength);
case 'character':
default:
return null;
}
}
private _getWordBoundaryOffset(
skeleton: DocumentSkeleton,
focusOffset: number,
direction: Direction,
segmentId: string,
segmentPage: number,
dataStreamLength: number
): Nullable<number> {
if (direction !== Direction.LEFT && direction !== Direction.RIGHT) {
return;
}
const glyph = skeleton.findNodeByCharIndex(focusOffset, segmentId, segmentPage)
?? skeleton.findNodeByCharIndex(Math.max(0, focusOffset - 1), segmentId, segmentPage);
if (glyph == null) {
return direction === Direction.LEFT ? 0 : dataStreamLength - 2;
}
const paragraphInfo = getParagraphInfoByGlyph(glyph);
if (paragraphInfo == null) {
return;
}
const nodeIndex = Math.min(Math.max(0, focusOffset - paragraphInfo.st), paragraphInfo.content.length);
const nextOffset = getNextWordBoundaryOffset(paragraphInfo.content, nodeIndex, paragraphInfo.st, direction);
if (nextOffset == null) {
return;
}
return Math.min(dataStreamLength - 2, Math.max(0, nextOffset));
}
private _getLineBoundaryOffset(
skeleton: DocumentSkeleton,
focusOffset: number,
direction: Direction,
segmentId: string,
segmentPage: number,
dataStreamLength: number
): Nullable<number> {
if (direction !== Direction.LEFT && direction !== Direction.RIGHT) {
return;
}
const glyph = skeleton.findNodeByCharIndex(focusOffset, segmentId, segmentPage)
?? skeleton.findNodeByCharIndex(Math.max(0, focusOffset - 1), segmentId, segmentPage);
const line = glyph?.parent?.parent;
if (line == null) {
return direction === Direction.LEFT ? 0 : dataStreamLength - 2;
}
const boundaryGlyph = direction === Direction.LEFT
? this._getFirstCursorGlyphInLine(line)
: this._getLastCursorGlyphInLine(line);
const boundaryPosition = boundaryGlyph == null
? undefined
: skeleton.findPositionByGlyph(boundaryGlyph, segmentPage);
if (boundaryPosition == null) {
return direction === Direction.LEFT ? 0 : dataStreamLength - 2;
}
const cursor = skeleton.findCharIndexByPosition({
...boundaryPosition,
isBack: direction === Direction.LEFT,
});
if (cursor == null) {
return;
}
return Math.min(dataStreamLength - 2, Math.max(0, cursor));
}
private _getFirstCursorGlyphInLine(line: IDocumentSkeletonLine): Nullable<IDocumentSkeletonGlyph> {
for (const divide of line.divides) {
for (const glyph of divide.glyphGroup) {
if (this._isCursorAddressableGlyph(glyph)) {
return glyph;
}
}
}
}
private _getLastCursorGlyphInLine(line: IDocumentSkeletonLine): Nullable<IDocumentSkeletonGlyph> {
for (let divideIndex = line.divides.length - 1; divideIndex >= 0; divideIndex--) {
const divide = line.divides[divideIndex];
for (let glyphIndex = divide.glyphGroup.length - 1; glyphIndex >= 0; glyphIndex--) {
const glyph = divide.glyphGroup[glyphIndex];
if (this._isCursorAddressableGlyph(glyph)) {
return glyph;
}
}
}
}
private _isCursorAddressableGlyph(glyph: IDocumentSkeletonGlyph): boolean {
return glyph.count > 0 && !this._getCursorSkipTokens(true).includes(glyph.streamType);
}
private _normalizeCursorOffset(
dataStream: string,
customRanges: IWholeEntityRange[],
cursor: number,
direction: Direction
): number {
cursor = Math.max(0, cursor);
const skipTokens = this._getCursorSkipTokens();
if (direction === Direction.LEFT || direction === Direction.UP) {
while (cursor > 0 && skipTokens.includes(dataStream[cursor])) {
cursor--;
}
} else {
while (cursor < dataStream.length - 1 && skipTokens.includes(dataStream[cursor])) {
cursor++;
}
}
return this._normalizeWholeEntityRanges(customRanges, cursor, direction);
}
private _normalizeWholeEntityRanges(
customRanges: IWholeEntityRange[],
cursor: number,
direction: Direction
): number {
const relativeRanges = customRanges.filter((range) => range.wholeEntity && range.startIndex < cursor && range.endIndex >= cursor);
relativeRanges.forEach((range) => {
if (direction === Direction.LEFT || direction === Direction.UP) {
cursor = Math.min(range.startIndex, cursor);
} else {
cursor = Math.max(range.endIndex + 1, cursor);
}
});
return cursor;
}
private _getCursorSkipTokens(includeParagraph = false): string[] {
const tokens = [
DataStreamTreeTokenType.TABLE_START,
DataStreamTreeTokenType.TABLE_END,
DataStreamTreeTokenType.TABLE_ROW_START,
DataStreamTreeTokenType.TABLE_ROW_END,
DataStreamTreeTokenType.TABLE_CELL_START,
DataStreamTreeTokenType.TABLE_CELL_END,
DataStreamTreeTokenType.SECTION_BREAK,
DataStreamTreeTokenType.BLOCK_START,
DataStreamTreeTokenType.BLOCK_END,
];
if (includeParagraph) {
tokens.push(DataStreamTreeTokenType.PARAGRAPH);
}
return tokens;
}
private _getTopOrBottomPosition(
docSkeleton: DocumentSkeleton,
glyph: Nullable<IDocumentSkeletonGlyph>,
@@ -416,7 +675,7 @@ export class DocMoveCursorController extends Disposable {
const divideLeft = divide.left;
for (const glyph of divide.glyphGroup) {
if (glyph.streamType === DataStreamTreeTokenType.SECTION_BREAK) {
if (!this._isCursorAddressableGlyph(glyph)) {
continue;
}
const { left } = glyph;
@@ -0,0 +1,284 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentBody, IParagraph } from '@univerjs/core';
import type { IDocumentSkeletonLine, IDocumentSkeletonPage } from '@univerjs/engine-render';
import { DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DocumentFlavor, NamedStyleType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { DocumentSkeletonPageType, GlyphType, LineType } from '@univerjs/engine-render';
import { getParagraphPlaceholderLayouts, shouldRenderParagraphPlaceholder } from '../doc-paragraph-placeholder.render-controller';
const locale = {
heading1: '标题1',
heading2: '标题2',
heading3: '标题3',
heading4: '标题4',
heading5: '标题5',
listItem: '项目',
normalText: '请输入文字或按"/"启用命令',
};
function createLine(paragraphIndex: number, options?: {
bullet?: boolean;
fontFamily?: string;
fontSize?: number;
paragraphStart?: boolean;
st?: number;
top?: number;
}): IDocumentSkeletonLine {
const fontSize = options?.fontSize ?? 12;
const glyph = {
glyphType: GlyphType.WORD,
streamType: '',
width: options?.bullet ? 12 : 0,
bBox: {
width: options?.bullet ? 12 : 0,
ba: fontSize,
bd: 2,
aba: fontSize,
abd: 2,
sp: 0,
sbr: 0,
sbo: 0,
spr: 0,
spo: 0,
},
xOffset: 0,
left: 0,
count: 1,
content: options?.bullet ? '1.' : '\r',
raw: options?.bullet ? '1.' : '\r',
adjustability: {
stretchability: [0, 0],
shrinkability: [0, 0],
},
ts: {
fs: fontSize,
ff: options?.fontFamily ?? 'Arial',
},
fontStyle: {
fontSize,
originFontSize: fontSize,
fontFamily: options?.fontFamily ?? 'Arial',
fontString: `${fontSize}px ${options?.fontFamily ?? 'Arial'}`,
fontCache: `${fontSize}px ${options?.fontFamily ?? 'Arial'}`,
},
};
return {
paragraphIndex,
type: LineType.PARAGRAPH,
divides: [{
glyphGroup: [glyph],
width: 240,
left: 8,
paddingLeft: 3,
isFull: false,
st: options?.st ?? paragraphIndex,
ed: paragraphIndex,
}],
divideLen: 1,
lineHeight: 24,
contentHeight: fontSize,
top: options?.top ?? 20,
asc: fontSize,
dsc: 2,
paddingTop: 2,
paddingBottom: 2,
marginTop: 1,
marginBottom: 0,
spaceBelowApply: 0,
st: options?.st ?? paragraphIndex,
ed: paragraphIndex,
lineIndex: 0,
paragraphStart: options?.paragraphStart ?? true,
isBehindTable: false,
tableId: '',
} as IDocumentSkeletonLine;
}
function createPage(lines: IDocumentSkeletonLine[]): IDocumentSkeletonPage {
const column = {
lines,
left: 10,
width: 300,
height: 120,
spaceWidth: 0,
separator: 0,
st: 0,
ed: 10,
drawingLRIds: [],
isFull: false,
} as any;
const section = {
columns: [column],
colCount: 1,
height: 120,
top: 30,
st: 0,
ed: 10,
} as any;
const page = {
sections: [section],
headerId: '',
footerId: '',
pageWidth: 500,
pageHeight: 700,
pageOrient: 0,
marginLeft: 40,
marginRight: 40,
originMarginTop: 50,
marginTop: 50,
originMarginBottom: 50,
marginBottom: 50,
left: 0,
pageNumber: 1,
pageNumberStart: 1,
verticalAlign: false,
angle: 0,
width: 400,
height: 600,
breakType: 0,
st: 0,
ed: 10,
skeDrawings: new Map(),
skeTables: new Map(),
segmentId: '',
type: DocumentSkeletonPageType.BODY,
} as IDocumentSkeletonPage;
section.parent = page;
column.parent = section;
lines.forEach((line) => {
line.parent = column;
line.divides.forEach((divide) => {
divide.parent = line;
divide.glyphGroup.forEach((glyph) => {
glyph.parent = divide;
});
});
});
return page;
}
function createBody(dataStream: string, paragraphs: IParagraph[]): IDocumentBody {
return {
dataStream,
paragraphs,
};
}
function createDocumentModel(documentFlavor: DocumentFlavor) {
return {
getSnapshot: () => ({
documentStyle: {
documentFlavor,
},
}),
} as any;
}
describe('doc paragraph placeholder render controller', () => {
it('only enables placeholder rendering for modern docs when config is enabled', () => {
expect(shouldRenderParagraphPlaceholder(createDocumentModel(DocumentFlavor.MODERN), 'doc-1', { placeholder: true })).toBe(true);
expect(shouldRenderParagraphPlaceholder(createDocumentModel(DocumentFlavor.TRADITIONAL), 'doc-1', { placeholder: true })).toBe(false);
expect(shouldRenderParagraphPlaceholder(createDocumentModel(DocumentFlavor.MODERN), 'doc-1', { placeholder: false })).toBe(false);
});
it('disables placeholder rendering for internal editors', () => {
expect(shouldRenderParagraphPlaceholder(createDocumentModel(DocumentFlavor.MODERN), DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, { placeholder: true })).toBe(false);
});
it('shows normal text placeholder for an empty normal paragraph', () => {
const page = createPage([createLine(0, { fontSize: 13, fontFamily: 'Inter' })]);
const body = createBody('\r\n', [{ startIndex: 0 }]);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale);
expect(placeholders).toMatchObject([{
text: '请输入文字或按"/"启用命令',
fontFamily: 'Inter',
fontSize: 13,
fontWeight: 'normal',
x: 61,
y: 116,
}]);
});
it('shows heading placeholder with the heading font size', () => {
const page = createPage([createLine(0, { fontSize: 20 })]);
const body = createBody('\r\n', [{
startIndex: 0,
paragraphStyle: {
namedStyleType: NamedStyleType.HEADING_1,
},
}]);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale);
expect(placeholders[0]).toMatchObject({
text: '标题1',
fontSize: 20,
fontWeight: 'bold',
});
});
it('shows list item placeholder after the marker when a list item has no text', () => {
const page = createPage([createLine(0, { bullet: true, fontSize: 14 })]);
const body = createBody('\r\n', [{
startIndex: 0,
bullet: {
listId: 'list-1',
listType: 'decimal',
nestingLevel: 0,
},
}]);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale);
expect(placeholders[0]).toMatchObject({
text: '项目',
fontSize: 14,
x: 77,
});
});
it('hides placeholder once the paragraph has text', () => {
const page = createPage([createLine(5, { st: 0 })]);
const body = createBody('Hello\r\n', [{ startIndex: 5 }]);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale);
expect(placeholders).toEqual([]);
});
it('only returns the active empty paragraph when an active offset is provided', () => {
const page = createPage([
createLine(0, { st: 0, top: 20 }),
createLine(1, { st: 1, top: 50 }),
]);
const body = createBody('\r\r\n', [{ startIndex: 0 }, { startIndex: 1 }]);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale, 0, 0, 1);
expect(placeholders).toHaveLength(1);
expect(placeholders[0]).toMatchObject({
text: '请输入文字或按"/"启用命令',
y: 145,
});
});
});
@@ -61,6 +61,9 @@ export class DocInputController extends Disposable implements IRenderModule {
const { event, content = '', activeRange } = config;
const e = event as InputEvent;
if (e.defaultPrevented) {
return;
}
const skeleton = this._docSkeletonManagerService.getSkeleton();
@@ -0,0 +1,298 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentBody, IParagraph, Nullable } from '@univerjs/core';
import type { Documents, IDocumentSkeletonColumn, IDocumentSkeletonLine, IDocumentSkeletonPage, IDocumentSkeletonSection, IPageRenderConfig, IRenderContext, IRenderModule, UniverRenderingContext } from '@univerjs/engine-render';
import type { IUniverDocsUIConfig } from '../../config/config';
import { DataStreamTreeTokenType, Disposable, DocumentFlavor, IConfigService, Inject, isInternalEditorID, LocaleService, NAMED_STYLE_MAP, NamedStyleType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DOCS_UI_PLUGIN_CONFIG_KEY } from '../../config/config';
const PLACEHOLDER_COLOR = 'rgba(0, 0, 0, 0.35)';
const DEFAULT_PLACEHOLDER_FONT_SIZE = 12;
const DEFAULT_PLACEHOLDER_FONT_FAMILY = 'Arial';
const LIST_PLACEHOLDER_GAP = 4;
export interface IParagraphPlaceholderLayout {
fontFamily: string;
fontSize: number;
fontWeight: string;
maxWidth: number;
text: string;
x: number;
y: number;
}
interface IParagraphPlaceholderLocale {
heading1: string;
heading2: string;
heading3: string;
heading4: string;
heading5: string;
listItem: string;
normalText: string;
}
export class DocParagraphPlaceholderRenderController extends Disposable implements IRenderModule {
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
this._initParagraphPlaceholderRender();
}
private _initParagraphPlaceholderRender(): void {
const config = this._configService.getConfig<IUniverDocsUIConfig>(DOCS_UI_PLUGIN_CONFIG_KEY);
if (!shouldRenderParagraphPlaceholder(this._context.unit, this._context.unitId, config)) {
return;
}
const documents = this._context.mainComponent as Documents | undefined;
if (!documents) {
return;
}
this.disposeWithMe(documents.pageRender$.subscribe((pageRenderConfig) => this._drawPagePlaceholders(pageRenderConfig)));
}
private _drawPagePlaceholders({ page, pageLeft, pageTop, ctx }: IPageRenderConfig): void {
const body = this._context.unit.getSelfOrHeaderFooterModel(page.segmentId)?.getBody();
if (!body) {
return;
}
const activeRange = this._docSelectionManagerService.getActiveTextRange();
if (!activeRange || (activeRange.segmentId ?? '') !== (page.segmentId ?? '')) {
return;
}
const placeholders = getParagraphPlaceholderLayouts(page, body, this._getLocale(), pageLeft, pageTop, activeRange.startOffset);
if (!placeholders.length) {
return;
}
drawParagraphPlaceholders(ctx, placeholders);
}
private _getLocale(): IParagraphPlaceholderLocale {
return {
heading1: this._localeService.t('docs-ui.placeholder.heading1'),
heading2: this._localeService.t('docs-ui.placeholder.heading2'),
heading3: this._localeService.t('docs-ui.placeholder.heading3'),
heading4: this._localeService.t('docs-ui.placeholder.heading4'),
heading5: this._localeService.t('docs-ui.placeholder.heading5'),
listItem: this._localeService.t('docs-ui.placeholder.listItem'),
normalText: this._localeService.t('docs-ui.placeholder.normalText'),
};
}
}
export function shouldRenderParagraphPlaceholder(
documentModel: DocumentDataModel,
unitId: string,
config?: Nullable<IUniverDocsUIConfig>
): boolean {
if (config?.placeholder === false) {
return false;
}
if (isInternalEditorID(unitId)) {
return false;
}
return documentModel.getSnapshot().documentStyle?.documentFlavor === DocumentFlavor.MODERN;
}
export function getParagraphPlaceholderLayouts(
page: IDocumentSkeletonPage,
body: IDocumentBody,
locale: IParagraphPlaceholderLocale,
pageLeft = 0,
pageTop = 0,
activeOffset?: number
): IParagraphPlaceholderLayout[] {
const paragraphs = new Map((body.paragraphs ?? []).map((paragraph) => [paragraph.startIndex, paragraph]));
const layouts: IParagraphPlaceholderLayout[] = [];
const visitSection = (section: IDocumentSkeletonSection, originLeft: number, originTop: number) => {
for (const column of section.columns) {
visitColumn(column, body, paragraphs, locale, layouts, originLeft + column.left, originTop + section.top, activeOffset);
}
};
for (const section of page.sections) {
visitSection(section, pageLeft + page.marginLeft, pageTop + page.marginTop);
}
page.skeTables?.forEach((table) => {
for (const row of table.rows) {
for (const cell of row.cells) {
for (const section of cell.sections) {
visitSection(
section,
pageLeft + page.marginLeft + table.left + cell.left + cell.marginLeft,
pageTop + page.marginTop + table.top + row.top + cell.marginTop
);
}
}
}
});
return layouts;
}
function visitColumn(
column: IDocumentSkeletonColumn,
body: IDocumentBody,
paragraphs: Map<number, IParagraph>,
locale: IParagraphPlaceholderLocale,
layouts: IParagraphPlaceholderLayout[],
originLeft: number,
originTop: number,
activeOffset?: number
): void {
for (const line of column.lines) {
if (!line.paragraphStart) {
continue;
}
const paragraphStart = line.st;
const paragraphEnd = line.paragraphIndex;
if (activeOffset != null && !isOffsetInParagraph(activeOffset, paragraphStart, paragraphEnd)) {
continue;
}
const paragraph = paragraphs.get(paragraphEnd);
if (!paragraph || !isEmptyParagraph(body.dataStream, line.st, line.paragraphIndex)) {
continue;
}
const text = getPlaceholderText(paragraph, locale);
if (!text) {
continue;
}
layouts.push(getLinePlaceholderLayout(line, paragraph, text, originLeft, originTop));
}
}
function isOffsetInParagraph(offset: number, paragraphStart: number, paragraphEnd: number): boolean {
return paragraphStart <= offset && offset <= paragraphEnd;
}
function getPlaceholderText(paragraph: IParagraph, locale: IParagraphPlaceholderLocale): string {
if (paragraph.bullet) {
return locale.listItem;
}
switch (paragraph.paragraphStyle?.namedStyleType) {
case NamedStyleType.HEADING_1:
return locale.heading1;
case NamedStyleType.HEADING_2:
return locale.heading2;
case NamedStyleType.HEADING_3:
return locale.heading3;
case NamedStyleType.HEADING_4:
return locale.heading4;
case NamedStyleType.HEADING_5:
return locale.heading5;
default:
return locale.normalText;
}
}
function getLinePlaceholderLayout(
line: IDocumentSkeletonLine,
paragraph: IParagraph,
text: string,
originLeft: number,
originTop: number
): IParagraphPlaceholderLayout {
const divide = line.divides[0];
const glyphs = divide?.glyphGroup ?? [];
const firstGlyph = glyphs[0];
const textStyle = NAMED_STYLE_MAP[paragraph.paragraphStyle?.namedStyleType ?? NamedStyleType.NORMAL_TEXT];
const fontSize = getLineFontSize(line) ?? textStyle?.fs ?? DEFAULT_PLACEHOLDER_FONT_SIZE;
const fontFamily = getLineFontFamily(line) ?? DEFAULT_PLACEHOLDER_FONT_FAMILY;
const fontWeight = textStyle?.bl ? 'bold' : 'normal';
const lineStartX = originLeft + (divide?.left ?? 0) + (divide?.paddingLeft ?? 0);
const listOffset = paragraph.bullet && firstGlyph ? firstGlyph.left + firstGlyph.width + LIST_PLACEHOLDER_GAP : 0;
return {
fontFamily,
fontSize,
fontWeight,
maxWidth: Math.max(0, (divide?.width ?? line.width ?? 0) - listOffset),
text,
x: lineStartX + listOffset,
y: originTop + line.top + line.marginTop + line.paddingTop + line.asc,
};
}
function isEmptyParagraph(dataStream: string | undefined, paragraphStart: number, paragraphEnd: number): boolean {
const text = dataStream?.slice(paragraphStart, paragraphEnd) ?? '';
return stripNonTextTokens(text).trim() === '';
}
function stripNonTextTokens(text: string): string {
return text
.replaceAll(DataStreamTreeTokenType.PARAGRAPH, '')
.replaceAll(DataStreamTreeTokenType.SECTION_BREAK, '')
.replaceAll(DataStreamTreeTokenType.DOCS_END, '');
}
function getLineFontSize(line: IDocumentSkeletonLine): Nullable<number> {
for (const divide of line.divides) {
for (const glyph of divide.glyphGroup) {
const fontSize = glyph.fontStyle?.originFontSize ?? glyph.fontStyle?.fontSize ?? glyph.ts?.fs;
if (fontSize) {
return fontSize;
}
}
}
return null;
}
function getLineFontFamily(line: IDocumentSkeletonLine): Nullable<string> {
for (const divide of line.divides) {
for (const glyph of divide.glyphGroup) {
const fontFamily = glyph.fontStyle?.fontFamily ?? glyph.ts?.ff;
if (fontFamily) {
return fontFamily;
}
}
}
return null;
}
function drawParagraphPlaceholders(ctx: UniverRenderingContext, placeholders: IParagraphPlaceholderLayout[]): void {
ctx.save();
ctx.fillStyle = PLACEHOLDER_COLOR;
ctx.textBaseline = 'alphabetic';
for (const placeholder of placeholders) {
ctx.font = `${placeholder.fontWeight} ${placeholder.fontSize}px ${placeholder.fontFamily}`;
ctx.fillText(placeholder.text, placeholder.x, placeholder.y, placeholder.maxWidth || undefined);
}
ctx.restore();
}
+1 -1
View File
@@ -110,6 +110,7 @@ export { getCommandSkeleton } from './commands/util';
export type { IUniverDocsUIConfig } from './config/config';
export { DocUIController } from './controllers/doc-ui.controller';
export { DocBackScrollRenderController } from './controllers/render-controllers/back-scroll.render-controller';
export { DocParagraphPlaceholderRenderController } from './controllers/render-controllers/doc-paragraph-placeholder.render-controller';
export { DocRenderController } from './controllers/render-controllers/doc.render-controller';
export {
AlignMenuItemFactory,
@@ -139,7 +140,6 @@ export {
EMPTY_PARAGRAPH_MENU_ID,
getDocBlockRangeMenuId,
INSERT_BELLOW_MENU_ID,
INSERT_DOC_SHAPE_COMMAND_ID,
ParagraphMenuInsertBelowSubmenuItemFactory,
} from './menu/paragraph-menu';
export { menuSchema as DocsUIMenuSchema } from './menu/schema';
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'إغلاق الرأس والتذييل',
disableText: 'إعدادات الرأس والتذييل معطلة',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'إعدادات الفقرة',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Tanca capçalera i peu de pàgina',
disableText: 'La configuració de capçalera i peu de pàgina està desactivada',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Configuració de paràgraf',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Kopf- und Fußzeile schließen',
disableText: 'Kopf- und Fußzeileneinstellungen sind deaktiviert',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Absatzeinstellungen',
+9
View File
@@ -74,6 +74,15 @@ const locale = {
closeHeaderFooter: 'Close header & footer',
disableText: 'Header & footer settings are disabled',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Paragraph Settings',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Cerrar encabezado y pie de página',
disableText: 'La configuración de encabezado y pie de página está deshabilitada',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Configuración de párrafo',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'بستن هدر و فوتر',
disableText: 'تنظیمات هدر و فوتر غیرفعال است',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'تنظیمات پاراگراف',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Fermer l\'en-tête et le pied de page',
disableText: 'Les paramètres de l\'en-tête et du pied de page sont désactivés',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Paramètres de paragraphe',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Tutup header & footer',
disableText: 'Pengaturan header & footer dinonaktifkan',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Pengaturan Paragraf',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Chiudi intestazione e piè di pagina',
disableText: 'Le impostazioni di intestazione e piè di pagina sono disabilitate',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Impostazioni Paragrafo',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'ヘッダー/フッターを閉じる',
disableText: 'ヘッダーとフッターの設定は無効です',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: '段落設定',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: '머리글 및 바닥글 닫기',
disableText: '머리글 및 바닥글 설정이 비활성화되었습니다',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: '문단 설정',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Zamknij nagłówek i stopkę',
disableText: 'Ustawienia nagłówka i stopki są wyłączone',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Ustawienia akapitu',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Fechar cabeçalho e rodapé',
disableText: 'As configurações de cabeçalho e rodapé estão desativadas',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Configurações de parágrafo',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Закрыть верхний и нижний колонтитулы',
disableText: 'Настройки верхнего и нижнего колонтитулов отключены',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Настройка абзаца',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Zavrieť hlavičku a pätu',
disableText: 'Nastavenia hlavičky a päty sú vypnuté',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Nastavenia odseku',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: 'Đóng đầu trang và chân trang',
disableText: 'Cài đặt đầu trang và chân trang không khả dụng',
},
placeholder: {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
normalText: 'Type text or press "/" for commands',
listItem: 'Item',
},
doc: {
menu: {
paragraphSetting: 'Paragraph Setting',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: '关闭页眉页脚',
disableText: '页眉页脚设置不可用',
},
placeholder: {
heading1: '标题1',
heading2: '标题2',
heading3: '标题3',
heading4: '标题4',
heading5: '标题5',
normalText: '请输入文字或按"/"启用命令',
listItem: '项目',
},
doc: {
menu: {
paragraphSetting: '段落设置',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: '關閉頁眉頁腳',
disableText: '頁眉頁腳設置不可用',
},
placeholder: {
heading1: '標題1',
heading2: '標題2',
heading3: '標題3',
heading4: '標題4',
heading5: '標題5',
normalText: '請輸入文字或按"/"啟用命令',
listItem: '項目',
},
doc: {
menu: {
paragraphSetting: 'Paragraph Setting',
+9
View File
@@ -76,6 +76,15 @@ const locale: typeof enUS = {
closeHeaderFooter: '關閉頁眉頁腳',
disableText: '頁眉頁腳設置不可用',
},
placeholder: {
heading1: '標題1',
heading2: '標題2',
heading3: '標題3',
heading4: '標題4',
heading5: '標題5',
normalText: '請輸入文字或按"/"啟用命令',
listItem: '項目',
},
doc: {
menu: {
paragraphSetting: 'Paragraph Setting',
@@ -42,24 +42,36 @@ import {
DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID,
DOC_PARAGRAPH_T_INSERT_MENU_ID,
DOC_TABLE_BLOCK_MENU_ID,
DOCS_CALLOUT_INSERT_BELOW_COMMAND_ID,
DOCS_CALLOUT_INSERT_COMMAND_ID,
DOCS_CODE_INSERT_BELOW_COMMAND_ID,
DOCS_CODE_INSERT_COMMAND_ID,
DOCS_QUOTE_INSERT_BELOW_COMMAND_ID,
DOCS_QUOTE_INSERT_COMMAND_ID,
EMPTY_PARAGRAPH_MENU_ID,
INSERT_BELLOW_MENU_ID,
INSERT_DOC_IMAGE_COMMAND_ID,
INSERT_DOC_SHAPE_COMMAND_ID,
InsertBulletListBellowMenuItemFactory,
InsertCheckListBellowMenuItemFactory,
InsertHorizontalLineBellowMenuItemFactory,
InsertOrderListBellowMenuItemFactory,
ParagraphMenuBackgroundColorHeaderActionMenuItemFactory,
ParagraphMenuDefaultTextColorMenuItemFactory,
ParagraphMenuInsertBelowShapeMenuItemFactory,
ParagraphMenuInsertShapeMenuItemFactory,
ParagraphMenuInsertBelowHeadingH1MenuItemFactory,
ParagraphMenuInsertBelowTableMenuItemFactory,
ParagraphMenuTextColorHeaderActionMenuItemFactory,
} from '../paragraph-menu';
import { menuSchema } from '../schema';
const OPTIONAL_INSERT_COMMAND_IDS = [
'docs-code.command.insert',
'docs-quote.command.insert',
'docs-callout.command.insert',
'doc.command.insert-float-image',
'doc.command.menu-insert-shape',
];
const OPTIONAL_INSERT_BELOW_COMMAND_IDS = [
'docs-code.command.insert-below',
'docs-quote.command.insert-below',
'docs-callout.command.insert-below',
'doc.command.insert-float-image.below',
'doc.command.menu-insert-shape.below',
];
describe('docs ui ribbon schema', () => {
it('uses one align dropdown instead of separate toolbar buttons', () => {
const layout = (menuSchema as any)[RibbonStartGroup.LAYOUT];
@@ -234,14 +246,10 @@ describe('docs ui ribbon schema', () => {
expect(Object.keys(quickBottom)).toEqual(expect.arrayContaining([
BulletListCommand.id,
CheckListCommand.id,
DOCS_CODE_INSERT_COMMAND_ID,
DOCS_QUOTE_INSERT_COMMAND_ID,
DOCS_CALLOUT_INSERT_COMMAND_ID,
HorizontalLineCommand.id,
]));
expect(insert[DocCreateTableOperation.id].menuItemFactory).toBe(InsertDefaultTableMenuFactory);
expect(insert[INSERT_DOC_IMAGE_COMMAND_ID].menuItemFactory).toBeDefined();
expect(insert[INSERT_DOC_SHAPE_COMMAND_ID].menuItemFactory).toBeDefined();
expect(OPTIONAL_INSERT_COMMAND_IDS.some((id) => quickBottom[id] || insert[id])).toBe(false);
});
it('builds the edit-state T menu with official submenus instead of a custom panel', () => {
@@ -258,9 +266,7 @@ describe('docs ui ribbon schema', () => {
expect(quickTop[NormalTextHeadingCommand.id].menuItemFactory).toBeDefined();
expect(quickTop[TitleHeadingCommand.id].menuItemFactory).toBeDefined();
expect(quickTop[SubtitleHeadingCommand.id].menuItemFactory).toBeDefined();
expect(quickBottom[DOCS_CODE_INSERT_COMMAND_ID].menuItemFactory).toBeDefined();
expect(quickBottom[DOCS_QUOTE_INSERT_COMMAND_ID].menuItemFactory).toBeDefined();
expect(quickBottom[DOCS_CALLOUT_INSERT_COMMAND_ID].menuItemFactory).toBeDefined();
expect(OPTIONAL_INSERT_COMMAND_IDS.some((id) => quickBottom[id])).toBe(false);
expect(layout[DOC_PARAGRAPH_T_ALIGN_MENU_ID].menuItemFactory).toBeDefined();
expect(layout[DOC_PARAGRAPH_T_COLORS_MENU_ID].menuItemFactory).toBeDefined();
expect(format[DocCutCurrentParagraphCommand.id].menuItemFactory).toBeDefined();
@@ -333,23 +339,37 @@ describe('docs ui ribbon schema', () => {
expect(Object.keys(insertBelowMenu.quickBottom)).toEqual(expect.arrayContaining([
InsertBulletListBellowCommand.id,
InsertCheckListBellowCommand.id,
DOCS_CODE_INSERT_BELOW_COMMAND_ID,
DOCS_QUOTE_INSERT_BELOW_COMMAND_ID,
DOCS_CALLOUT_INSERT_BELOW_COMMAND_ID,
InsertHorizontalLineBellowCommand.id,
]));
expect(insertBelowMenu.insert[`${DocCreateTableOperation.id}.below`].menuItemFactory).toBeDefined();
expect(insertBelowMenu.insert[`${INSERT_DOC_IMAGE_COMMAND_ID}.below`].menuItemFactory).toBeDefined();
expect(insertBelowMenu.insert[`${INSERT_DOC_SHAPE_COMMAND_ID}.below`].menuItemFactory).toBeDefined();
expect(OPTIONAL_INSERT_BELOW_COMMAND_IDS.some((id) => insertBelowMenu.quickBottom[id] || insertBelowMenu.insert[id])).toBe(false);
});
it('uses official shape submenus for paragraph insert shape actions', () => {
const rootShapeItem = ParagraphMenuInsertShapeMenuItemFactory({ get: () => ({ get: () => undefined, register: () => undefined }) } as never);
const belowShapeItem = ParagraphMenuInsertBelowShapeMenuItemFactory({ get: () => ({ get: () => undefined, register: () => undefined }) } as never);
it('registers icons needed by paragraph T insert-below tiny menu items', () => {
const registered = new Set<string>();
const accessor = {
get: () => ({
get: (key: string) => registered.has(key),
register: (key: string) => registered.add(key),
}),
} as never;
expect(rootShapeItem.type).toBe(MenuItemType.SUBITEMS);
expect(rootShapeItem.id).toBe(INSERT_DOC_SHAPE_COMMAND_ID);
expect(belowShapeItem.type).toBe(MenuItemType.SUBITEMS);
expect(belowShapeItem.id).toBe(`${INSERT_DOC_SHAPE_COMMAND_ID}.below`);
[
ParagraphMenuInsertBelowHeadingH1MenuItemFactory,
InsertOrderListBellowMenuItemFactory,
InsertBulletListBellowMenuItemFactory,
InsertCheckListBellowMenuItemFactory,
InsertHorizontalLineBellowMenuItemFactory,
ParagraphMenuInsertBelowTableMenuItemFactory,
].forEach((factory) => factory(accessor));
expect([...registered]).toEqual(expect.arrayContaining([
'H1Icon',
'OrderIcon',
'UnorderIcon',
'TodoListDoubleIcon',
'ReduceIcon',
'GridIcon',
]));
});
});
+30 -92
View File
@@ -20,8 +20,7 @@ import type { ComponentType } from 'react';
import { ICommandService, NamedStyleType, UniverInstanceType } from '@univerjs/core';
import { SetTextSelectionsOperation } from '@univerjs/docs';
import {
CalloutIcon,
CodeBlockIcon,
GridIcon,
H1Icon,
H2Icon,
H3Icon,
@@ -29,9 +28,11 @@ import {
H5Icon,
MoreLeftIcon,
MoreRightIcon,
QuoteIcon,
ShapeIcon,
OrderIcon,
ReduceIcon,
TextTypeIcon,
TodoListDoubleIcon,
UnorderIcon,
} from '@univerjs/icons';
import { ComponentManager, getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { createElement } from 'react';
@@ -225,6 +226,8 @@ const createEmptyParagraphButtonFactory = (
const headingIcon = Object.values(HEADING_ICON_MAP).find((item) => item.key === icon);
if (headingIcon && !componentManager.get(headingIcon.key)) {
componentManager.register(headingIcon.key, headingIcon.component);
} else {
ensureParagraphMenuIcon(componentManager, icon);
}
return {
@@ -275,7 +278,9 @@ export const DeleteCurrentParagraphMenuItemFactory = (_accessor: IAccessor): IMe
};
};
export const InsertBulletListBellowMenuItemFactory = (_accessor: IAccessor): IMenuItem => {
export const InsertBulletListBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => {
ensureParagraphMenuIcon(accessor.get(ComponentManager), 'UnorderIcon');
return {
id: InsertBulletListBellowCommand.id,
type: MenuItemType.BUTTON,
@@ -285,7 +290,9 @@ export const InsertBulletListBellowMenuItemFactory = (_accessor: IAccessor): IMe
};
};
export const InsertOrderListBellowMenuItemFactory = (_accessor: IAccessor): IMenuItem => {
export const InsertOrderListBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => {
ensureParagraphMenuIcon(accessor.get(ComponentManager), 'OrderIcon');
return {
id: InsertOrderListBellowCommand.id,
type: MenuItemType.BUTTON,
@@ -295,7 +302,9 @@ export const InsertOrderListBellowMenuItemFactory = (_accessor: IAccessor): IMen
};
};
export const InsertCheckListBellowMenuItemFactory = (_accessor: IAccessor): IMenuItem => {
export const InsertCheckListBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => {
ensureParagraphMenuIcon(accessor.get(ComponentManager), 'TodoListDoubleIcon');
return {
id: InsertCheckListBellowCommand.id,
type: MenuItemType.BUTTON,
@@ -305,7 +314,9 @@ export const InsertCheckListBellowMenuItemFactory = (_accessor: IAccessor): IMen
};
};
export const InsertHorizontalLineBellowMenuItemFactory = (_accessor: IAccessor): IMenuItem => {
export const InsertHorizontalLineBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => {
ensureParagraphMenuIcon(accessor.get(ComponentManager), 'ReduceIcon');
return {
id: InsertHorizontalLineBellowCommand.id,
type: MenuItemType.BUTTON,
@@ -328,14 +339,6 @@ export const DOC_PARAGRAPH_T_RESET_COLORS_ID = 'doc.menu.paragraph-t.reset-color
export const DOC_PARAGRAPH_T_INDENT_INCREASE_ID = 'doc.menu.paragraph-t.indent.increase';
export const DOC_PARAGRAPH_T_INDENT_DECREASE_ID = 'doc.menu.paragraph-t.indent.decrease';
export const DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID = 'doc.menu.paragraph-t.insert-below.command';
export const DOCS_CODE_INSERT_COMMAND_ID = 'docs-code.command.insert';
export const DOCS_CODE_INSERT_BELOW_COMMAND_ID = 'docs-code.command.insert-below';
export const DOCS_QUOTE_INSERT_COMMAND_ID = 'docs-quote.command.insert';
export const DOCS_QUOTE_INSERT_BELOW_COMMAND_ID = 'docs-quote.command.insert-below';
export const DOCS_CALLOUT_INSERT_COMMAND_ID = 'docs-callout.command.insert';
export const DOCS_CALLOUT_INSERT_BELOW_COMMAND_ID = 'docs-callout.command.insert-below';
export const INSERT_DOC_IMAGE_COMMAND_ID = 'doc.command.insert-float-image';
export const INSERT_DOC_SHAPE_COMMAND_ID = 'doc.command.menu-insert-shape';
const TEXT_COLORS = ['#FE4B4B', '#FF8C51', '#A4DC16', '#2DAEFF', '#3A60F7', '#9E6DE3', '#F248A6'];
const BACKGROUND_COLORS = [
@@ -365,17 +368,24 @@ function ensureParagraphMenuIcon(componentManager: ComponentManager, icon: strin
return;
}
const headingIcon = Object.values(HEADING_ICON_MAP).find((item) => item.key === icon);
if (headingIcon) {
componentManager.register(headingIcon.key, headingIcon.component);
return;
}
const mapping: Partial<Record<string, ComponentType<{ className: string }>>> = {
TitleTypeIcon,
SubtitleTypeIcon,
DefaultTextColorIcon,
HeaderTextColorIcon,
CodeBlockIcon,
QuoteIcon,
CalloutIcon,
ShapeIcon,
MoreRightIcon,
MoreLeftIcon,
OrderIcon,
UnorderIcon,
TodoListDoubleIcon,
ReduceIcon,
GridIcon,
};
const component = mapping[icon];
@@ -622,41 +632,6 @@ export const ParagraphMenuInsertBelowSubmenuItemFactory = createStaticSubmenuMen
tooltip: 'docs-ui.rightClick.insertBellow',
});
export const ParagraphMenuInsertImageMenuItemFactory = createStaticButtonMenuItemFactory({
id: INSERT_DOC_IMAGE_COMMAND_ID,
icon: 'AddImageIcon',
title: 'docs-drawing-ui.upload.float',
tooltip: 'docs-drawing-ui.upload.float',
});
export const ParagraphMenuInsertShapeMenuItemFactory = createStaticSubmenuMenuItemFactory({
id: INSERT_DOC_SHAPE_COMMAND_ID,
icon: 'ShapeIcon',
title: 'Insert Shape',
tooltip: 'Insert Shape',
});
export const ParagraphMenuInsertCodeMenuItemFactory = createStaticButtonMenuItemFactory({
id: DOCS_CODE_INSERT_COMMAND_ID,
icon: 'CodeBlockIcon',
title: 'docs-code-ui.menu.code',
tooltip: 'docs-code-ui.menu.code',
});
export const ParagraphMenuInsertQuoteMenuItemFactory = createStaticButtonMenuItemFactory({
id: DOCS_QUOTE_INSERT_COMMAND_ID,
icon: 'QuoteIcon',
title: 'docs-quote-ui.menu.quote',
tooltip: 'docs-quote-ui.menu.quote',
});
export const ParagraphMenuInsertCalloutMenuItemFactory = createStaticButtonMenuItemFactory({
id: DOCS_CALLOUT_INSERT_COMMAND_ID,
icon: 'CalloutIcon',
title: 'docs-callout-ui.menu.callout',
tooltip: 'docs-callout-ui.menu.callout',
});
export const ParagraphMenuInsertBelowHeadingH1MenuItemFactory = createStaticButtonMenuItemFactory({
id: `${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h1`,
commandId: DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID,
@@ -702,22 +677,6 @@ export const ParagraphMenuInsertBelowHeadingH5MenuItemFactory = createStaticButt
params: { commandId: H5HeadingCommand.id, paragraphMenuPlacement: 'below', paragraphMenuInsertMode: 'breakline' },
});
export const ParagraphMenuInsertBelowImageMenuItemFactory = createStaticButtonMenuItemFactory({
id: `${INSERT_DOC_IMAGE_COMMAND_ID}.below`,
commandId: INSERT_DOC_IMAGE_COMMAND_ID,
icon: 'AddImageIcon',
title: 'docs-drawing-ui.upload.float',
tooltip: 'docs-drawing-ui.upload.float',
params: { paragraphMenuPlacement: 'below' },
});
export const ParagraphMenuInsertBelowShapeMenuItemFactory = createStaticSubmenuMenuItemFactory({
id: `${INSERT_DOC_SHAPE_COMMAND_ID}.below`,
icon: 'ShapeIcon',
title: 'Insert Shape',
tooltip: 'Insert Shape',
});
export const ParagraphMenuInsertBelowTableMenuItemFactory = createStaticButtonMenuItemFactory({
id: `${DocCreateTableOperation.id}.below`,
commandId: DocCreateTableOperation.id,
@@ -727,27 +686,6 @@ export const ParagraphMenuInsertBelowTableMenuItemFactory = createStaticButtonMe
params: { rowCount: 3, colCount: 5, paragraphMenuPlacement: 'below' },
});
export const ParagraphMenuInsertBelowCodeMenuItemFactory = createStaticButtonMenuItemFactory({
id: DOCS_CODE_INSERT_BELOW_COMMAND_ID,
icon: 'CodeBlockIcon',
title: 'docs-code-ui.menu.code',
tooltip: 'docs-code-ui.menu.code',
});
export const ParagraphMenuInsertBelowQuoteMenuItemFactory = createStaticButtonMenuItemFactory({
id: DOCS_QUOTE_INSERT_BELOW_COMMAND_ID,
icon: 'QuoteIcon',
title: 'docs-quote-ui.menu.quote',
tooltip: 'docs-quote-ui.menu.quote',
});
export const ParagraphMenuInsertBelowCalloutMenuItemFactory = createStaticButtonMenuItemFactory({
id: DOCS_CALLOUT_INSERT_BELOW_COMMAND_ID,
icon: 'CalloutIcon',
title: 'docs-callout-ui.menu.callout',
tooltip: 'docs-callout-ui.menu.callout',
});
export const ParagraphMenuIndentIncreaseMenuItemFactory = createStaticButtonMenuItemFactory({
id: DOC_PARAGRAPH_T_INDENT_INCREASE_ID,
icon: 'MoreRightIcon',
-70
View File
@@ -99,12 +99,6 @@ import {
DOC_PARAGRAPH_T_INSERT_MENU_ID,
DOC_TABLE_BLOCK_MENU_ID,
DocInsertBellowMenuItemFactory,
DOCS_CALLOUT_INSERT_BELOW_COMMAND_ID,
DOCS_CALLOUT_INSERT_COMMAND_ID,
DOCS_CODE_INSERT_BELOW_COMMAND_ID,
DOCS_CODE_INSERT_COMMAND_ID,
DOCS_QUOTE_INSERT_BELOW_COMMAND_ID,
DOCS_QUOTE_INSERT_COMMAND_ID,
EMPTY_PARAGRAPH_MENU_ID,
EmptyParagraphBulletListMenuItemFactory,
EmptyParagraphCheckListMenuItemFactory,
@@ -122,8 +116,6 @@ import {
H4HeadingMenuItemFactory,
H5HeadingMenuItemFactory,
INSERT_BELLOW_MENU_ID,
INSERT_DOC_IMAGE_COMMAND_ID,
INSERT_DOC_SHAPE_COMMAND_ID,
InsertBulletListBellowMenuItemFactory,
InsertCheckListBellowMenuItemFactory,
InsertHorizontalLineBellowMenuItemFactory,
@@ -136,23 +128,13 @@ import {
ParagraphMenuDefaultTextColorMenuItemFactory,
ParagraphMenuIndentDecreaseMenuItemFactory,
ParagraphMenuIndentIncreaseMenuItemFactory,
ParagraphMenuInsertBelowCalloutMenuItemFactory,
ParagraphMenuInsertBelowCodeMenuItemFactory,
ParagraphMenuInsertBelowHeadingH1MenuItemFactory,
ParagraphMenuInsertBelowHeadingH2MenuItemFactory,
ParagraphMenuInsertBelowHeadingH3MenuItemFactory,
ParagraphMenuInsertBelowHeadingH4MenuItemFactory,
ParagraphMenuInsertBelowHeadingH5MenuItemFactory,
ParagraphMenuInsertBelowImageMenuItemFactory,
ParagraphMenuInsertBelowQuoteMenuItemFactory,
ParagraphMenuInsertBelowShapeMenuItemFactory,
ParagraphMenuInsertBelowSubmenuItemFactory,
ParagraphMenuInsertBelowTableMenuItemFactory,
ParagraphMenuInsertCalloutMenuItemFactory,
ParagraphMenuInsertCodeMenuItemFactory,
ParagraphMenuInsertImageMenuItemFactory,
ParagraphMenuInsertQuoteMenuItemFactory,
ParagraphMenuInsertShapeMenuItemFactory,
ParagraphMenuNoBackgroundMenuItemFactory,
ParagraphMenuResetTextColorMenuItemFactory,
ParagraphMenuTextColorHeaderActionMenuItemFactory,
@@ -609,18 +591,6 @@ export const menuSchema: MenuSchemaType = {
order: 1,
menuItemFactory: EmptyParagraphCheckListMenuItemFactory,
},
[DOCS_CODE_INSERT_COMMAND_ID]: {
order: 2,
menuItemFactory: ParagraphMenuInsertCodeMenuItemFactory,
},
[DOCS_QUOTE_INSERT_COMMAND_ID]: {
order: 3,
menuItemFactory: ParagraphMenuInsertQuoteMenuItemFactory,
},
[DOCS_CALLOUT_INSERT_COMMAND_ID]: {
order: 4,
menuItemFactory: ParagraphMenuInsertCalloutMenuItemFactory,
},
[HorizontalLineCommand.id]: {
order: 5,
menuItemFactory: EmptyParagraphHorizontalLineMenuItemFactory,
@@ -632,14 +602,6 @@ export const menuSchema: MenuSchemaType = {
order: 0,
menuItemFactory: InsertDefaultTableMenuFactory,
},
[INSERT_DOC_IMAGE_COMMAND_ID]: {
order: 1,
menuItemFactory: ParagraphMenuInsertImageMenuItemFactory,
},
[INSERT_DOC_SHAPE_COMMAND_ID]: {
order: 2,
menuItemFactory: ParagraphMenuInsertShapeMenuItemFactory,
},
},
},
[DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID]: {
@@ -682,18 +644,6 @@ export const menuSchema: MenuSchemaType = {
order: 1,
menuItemFactory: InsertCheckListBellowMenuItemFactory,
},
[DOCS_CODE_INSERT_BELOW_COMMAND_ID]: {
order: 2,
menuItemFactory: ParagraphMenuInsertBelowCodeMenuItemFactory,
},
[DOCS_QUOTE_INSERT_BELOW_COMMAND_ID]: {
order: 3,
menuItemFactory: ParagraphMenuInsertBelowQuoteMenuItemFactory,
},
[DOCS_CALLOUT_INSERT_BELOW_COMMAND_ID]: {
order: 4,
menuItemFactory: ParagraphMenuInsertBelowCalloutMenuItemFactory,
},
[InsertHorizontalLineBellowCommand.id]: {
order: 5,
menuItemFactory: InsertHorizontalLineBellowMenuItemFactory,
@@ -705,14 +655,6 @@ export const menuSchema: MenuSchemaType = {
order: 0,
menuItemFactory: ParagraphMenuInsertBelowTableMenuItemFactory,
},
[`${INSERT_DOC_IMAGE_COMMAND_ID}.below`]: {
order: 1,
menuItemFactory: ParagraphMenuInsertBelowImageMenuItemFactory,
},
[`${INSERT_DOC_SHAPE_COMMAND_ID}.below`]: {
order: 2,
menuItemFactory: ParagraphMenuInsertBelowShapeMenuItemFactory,
},
},
},
[DOC_PARAGRAPH_T_EDIT_MENU_ID]: {
@@ -767,18 +709,6 @@ export const menuSchema: MenuSchemaType = {
order: 2,
menuItemFactory: CheckListMenuItemFactory,
},
[DOCS_CODE_INSERT_COMMAND_ID]: {
order: 3,
menuItemFactory: ParagraphMenuInsertCodeMenuItemFactory,
},
[DOCS_QUOTE_INSERT_COMMAND_ID]: {
order: 4,
menuItemFactory: ParagraphMenuInsertQuoteMenuItemFactory,
},
[DOCS_CALLOUT_INSERT_COMMAND_ID]: {
order: 5,
menuItemFactory: ParagraphMenuInsertCalloutMenuItemFactory,
},
},
layout: {
order: 2,
+35 -1
View File
@@ -111,6 +111,7 @@ import { DocEditorBridgeController } from './controllers/render-controllers/doc-
import { DocIMEInputController } from './controllers/render-controllers/doc-ime-input.controller';
import { DocInputController } from './controllers/render-controllers/doc-input.controller';
import { DocResizeRenderController } from './controllers/render-controllers/doc-resize.render-controller';
import { DocParagraphPlaceholderRenderController } from './controllers/render-controllers/doc-paragraph-placeholder.render-controller';
import { DocSelectionRenderController } from './controllers/render-controllers/doc-selection-render.controller';
import { DocRenderController } from './controllers/render-controllers/doc.render-controller';
import { DocZoomRenderController } from './controllers/render-controllers/zoom.render-controller';
@@ -128,19 +129,32 @@ import { DocsRenderService } from './services/docs-render.service';
import { EditorService, IEditorService } from './services/editor/editor-manager.service';
import { DocFloatMenuService } from './services/float-menu.service';
import { DocSelectionRenderService } from './services/selection/doc-selection-render.service';
import { BreakLineShortcut, DeleteLeftShortcut, DeleteRightShortcut } from './shortcuts/core-editing.shortcut';
import { BreakLineShortcut, DeleteLeftShortcut, DeleteRightShortcut, SoftBreakLineShortcut } from './shortcuts/core-editing.shortcut';
import {
MoveCursorDocumentEndShortcut,
MoveCursorDocumentStartShortcut,
MoveCursorDownShortcut,
MoveCursorLeftShortcut,
MoveCursorLineEndShortcut,
MoveCursorLineStartShortcut,
MoveCursorRightShortcut,
MoveCursorUpShortcut,
MoveCursorWordLeftShortcut,
MoveCursorWordRightShortcut,
MoveSelectionDocumentEndShortcut,
MoveSelectionDocumentStartShortcut,
MoveSelectionDownShortcut,
MoveSelectionLeftShortcut,
MoveSelectionLineEndShortcut,
MoveSelectionLineStartShortcut,
MoveSelectionRightShortcut,
MoveSelectionUpShortcut,
MoveSelectionWordLeftShortcut,
MoveSelectionWordRightShortcut,
SelectAllShortcut,
} from './shortcuts/cursor.shortcut';
import { ShiftTabShortCut } from './shortcuts/format.shortcut';
import { H1HeadingShortcut, H2HeadingShortcut, H3HeadingShortcut, H4HeadingShortcut, H5HeadingShortcut, NormalTextHeadingShortcut } from './shortcuts/heading.shortcut';
@DependentOn(UniverRenderEnginePlugin)
export class UniverDocsUIPlugin extends Plugin {
@@ -305,11 +319,30 @@ export class UniverDocsUIPlugin extends Plugin {
MoveSelectionDownShortcut,
MoveSelectionLeftShortcut,
MoveSelectionRightShortcut,
MoveCursorLineStartShortcut,
MoveCursorLineEndShortcut,
MoveSelectionLineStartShortcut,
MoveSelectionLineEndShortcut,
MoveCursorDocumentStartShortcut,
MoveCursorDocumentEndShortcut,
MoveSelectionDocumentStartShortcut,
MoveSelectionDocumentEndShortcut,
MoveCursorWordLeftShortcut,
MoveCursorWordRightShortcut,
MoveSelectionWordLeftShortcut,
MoveSelectionWordRightShortcut,
SelectAllShortcut,
DeleteLeftShortcut,
DeleteRightShortcut,
BreakLineShortcut,
SoftBreakLineShortcut,
ShiftTabShortCut,
NormalTextHeadingShortcut,
H1HeadingShortcut,
H2HeadingShortcut,
H3HeadingShortcut,
H4HeadingShortcut,
H5HeadingShortcut,
].forEach((shortcut) => {
this._injector.get(IShortcutService).registerShortcut(shortcut);
});
@@ -376,6 +409,7 @@ export class UniverDocsUIPlugin extends Plugin {
[DocSelectionRenderController],
[DocHeaderFooterController],
[DocResizeRenderController],
[DocParagraphPlaceholderRenderController],
[DocContextMenuRenderController],
[DocChecklistRenderController],
[DocClipboardController],
@@ -427,6 +427,265 @@ describe('DocParagraphMenuService', () => {
expect(dispose).toHaveBeenCalledTimes(1);
expect(service.activeTarget).toBeNull();
});
it('hides the paragraph menu on keyboard input', () => {
const dispose = vi.fn();
const keydown$ = new Subject();
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose }));
const service = createService({
attachPopupToRect,
dataStream: 'Title\r',
keydown$,
});
service.showParagraphMenu(createParagraphBound({
paragraphStart: 0,
paragraphEnd: 5,
startIndex: 5,
}));
keydown$.next({ event: { key: 'a' } });
expect(dispose).toHaveBeenCalledTimes(1);
expect(service.activeTarget).toBeNull();
});
it('intercepts slash keydown and requests the insert menu without inserting slash', () => {
const dispose = vi.fn();
const keydown$ = new Subject();
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose }));
const service = createService({
attachPopupToRect,
dataStream: 'Title\r',
keydown$,
});
const slashRequests: unknown[] = [];
const preventDefault = vi.fn();
const stopPropagation = vi.fn();
const paragraph = createParagraphBound({
paragraphStart: 0,
paragraphEnd: 5,
startIndex: 5,
});
service.slashMenuRequest$.subscribe((request) => {
if (request) {
slashRequests.push(request);
}
});
service.showParagraphMenu(paragraph);
keydown$.next({
activeRange: { startOffset: 2, endOffset: 2, collapsed: true },
event: { key: '/', preventDefault, stopPropagation },
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(stopPropagation).toHaveBeenCalledTimes(1);
expect(dispose).not.toHaveBeenCalled();
expect(service.activeTarget?.kind).toBe('paragraph');
expect(slashRequests).toHaveLength(1);
expect(slashRequests[0]).toMatchObject({
anchorRect: paragraph.firstLine,
});
});
it('opens the slash insert menu from the current cursor paragraph when no menu is active', () => {
const keydown$ = new Subject();
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const paragraph = createParagraphBound({
paragraphStart: 0,
paragraphEnd: 5,
startIndex: 5,
});
const service = createService({
attachPopupToRect,
dataStream: 'Title\r',
keydown$,
paragraphBounds: new Map([[5, paragraph]]),
});
const slashRequests: unknown[] = [];
service.slashMenuRequest$.subscribe((request) => {
if (request) {
slashRequests.push(request);
}
});
keydown$.next({
activeRange: { startOffset: 2, endOffset: 2, collapsed: true },
event: { key: '/', preventDefault: vi.fn(), stopPropagation: vi.fn() },
});
expect(attachPopupToRect).toHaveBeenCalledTimes(1);
expect(service.activeTarget?.kind).toBe('paragraph');
expect(slashRequests).toHaveLength(1);
});
it('opens the slash insert menu inside a paragraph that already has text', () => {
const keydown$ = new Subject();
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const paragraph = createParagraphBound({
paragraphStart: 0,
paragraphEnd: 11,
startIndex: 11,
});
const service = createService({
attachPopupToRect,
dataStream: 'Hello world\r',
keydown$,
paragraphBounds: new Map([[11, paragraph]]),
});
const slashRequests: unknown[] = [];
service.slashMenuRequest$.subscribe((request) => {
if (request) {
slashRequests.push(request);
}
});
keydown$.next({
activeRange: { startOffset: 5, endOffset: 5, collapsed: true },
event: { key: '/', preventDefault: vi.fn(), stopPropagation: vi.fn() },
});
expect(attachPopupToRect).toHaveBeenCalledTimes(1);
expect(service.activeTarget?.kind).toBe('paragraph');
expect(service.activeTarget?.emptyMode).toBe(false);
expect(slashRequests).toHaveLength(1);
});
it('opens the slash insert menu inside an existing list paragraph', () => {
const keydown$ = new Subject();
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const paragraph = createParagraphBound({
paragraphStart: 0,
paragraphEnd: 9,
startIndex: 9,
});
const service = createService({
attachPopupToRect,
dataStream: 'List item\r',
keydown$,
paragraphBounds: new Map([[9, paragraph]]),
paragraphs: [{
bullet: { listType: PresetListType.BULLET_LIST },
startIndex: 9,
}],
});
const slashRequests: unknown[] = [];
service.slashMenuRequest$.subscribe((request) => {
if (request) {
slashRequests.push(request);
}
});
keydown$.next({
activeRange: { startOffset: 4, endOffset: 4, collapsed: true },
event: { key: '/', preventDefault: vi.fn(), stopPropagation: vi.fn() },
});
expect(attachPopupToRect).toHaveBeenCalledTimes(1);
expect(service.activeTarget?.kind).toBe('paragraph');
expect(service.activeTarget?.icon).toBe('UnorderIcon');
expect(slashRequests).toHaveLength(1);
});
it('falls back to opening the slash insert menu from input-before without inserting slash', () => {
const inputBefore$ = new Subject();
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const preventDefault = vi.fn();
const stopPropagation = vi.fn();
const paragraph = createParagraphBound({
paragraphStart: 0,
paragraphEnd: 11,
startIndex: 11,
});
const service = createService({
attachPopupToRect,
dataStream: 'Hello world\r',
inputBefore$,
paragraphBounds: new Map([[11, paragraph]]),
});
const slashRequests: unknown[] = [];
service.slashMenuRequest$.subscribe((request) => {
if (request) {
slashRequests.push(request);
}
});
inputBefore$.next({
activeRange: { startOffset: 5, endOffset: 5, collapsed: true },
content: '/',
event: { data: '/', preventDefault, stopPropagation },
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(stopPropagation).toHaveBeenCalledTimes(1);
expect(attachPopupToRect).toHaveBeenCalledTimes(1);
expect(service.activeTarget?.kind).toBe('paragraph');
expect(slashRequests).toHaveLength(1);
});
it('hides the slash insert menu immediately when clicking back into the document body', () => {
const dispose = vi.fn();
const keydown$ = new Subject();
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose }));
const paragraph = createParagraphBound({
paragraphStart: 0,
paragraphEnd: 5,
startIndex: 5,
});
const service = createService({
attachPopupToRect,
dataStream: 'Title\r',
keydown$,
paragraphBounds: new Map([[5, paragraph]]),
});
keydown$.next({
activeRange: { startOffset: 2, endOffset: 2, collapsed: true },
event: { key: '/', preventDefault: vi.fn(), stopPropagation: vi.fn() },
});
const [, popupOptions] = attachPopupToRect.mock.calls[0] as unknown as [unknown, { onClickOutside: () => void }];
popupOptions.onClickOutside();
expect(dispose).toHaveBeenCalledTimes(1);
expect(service.activeTarget).toBeNull();
});
it('clears the slash insert menu request after hiding so hover does not reopen it', () => {
const keydown$ = new Subject();
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const paragraph = createParagraphBound({
paragraphStart: 0,
paragraphEnd: 5,
startIndex: 5,
});
const service = createService({
attachPopupToRect,
dataStream: 'Title\r',
keydown$,
paragraphBounds: new Map([[5, paragraph]]),
});
keydown$.next({
activeRange: { startOffset: 2, endOffset: 2, collapsed: true },
event: { key: '/', preventDefault: vi.fn(), stopPropagation: vi.fn() },
});
const [, popupOptions] = attachPopupToRect.mock.calls[0] as unknown as [unknown, { onClickOutside: () => void }];
popupOptions.onClickOutside();
const replayedRequests: unknown[] = [];
const subscription = service.slashMenuRequest$.subscribe((request) => replayedRequests.push(request));
subscription.unsubscribe();
expect(replayedRequests).toEqual([null]);
});
});
function createService(options: {
@@ -436,6 +695,8 @@ function createService(options: {
findParagraphBoundByIndex?: (index: number) => unknown;
paragraphs?: Array<{ bullet?: { listType?: PresetListType }; startIndex: number }>;
paragraphBounds?: Map<number, IMutiPageParagraphBound>;
inputBefore$?: Subject<unknown>;
keydown$?: Subject<unknown>;
tableCellBounds?: Map<string, Array<{ colIndex: number; pageIndex: number; rect: { bottom: number; left: number; right: number; top: number }; rowIndex: number; tableId: string }>>;
tables?: Array<{ endIndex: number; startIndex: number; tableId: string }>;
viewportScrollY?: number;
@@ -497,6 +758,10 @@ function createService(options: {
} as never,
{
floatMenu: null,
} as never,
{
onInputBefore$: options.inputBefore$ ?? new Subject(),
onKeydown$: options.keydown$ ?? new Subject(),
} as never
);
}
@@ -15,16 +15,18 @@
*/
import type { DocumentDataModel, ICustomBlock, ICustomTable, IDocumentBlockRange, INeedCheckDisposable, Nullable } from '@univerjs/core';
import type { IBoundRectNoAngle, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IBoundRectNoAngle, IRenderContext, IRenderModule, ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IMutiPageParagraphBound, ITableBound, ITableParagraphBound } from './doc-event-manager.service';
import type { IEditorInputConfig } from './selection/doc-selection-render.service';
import { BlockType, DataStreamTreeTokenType, Disposable, Inject, isInternalEditorID, PresetListType } from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { DocumentEditArea } from '@univerjs/engine-render';
import { BehaviorSubject, combineLatest, first, throttleTime } from 'rxjs';
import { VIEWPORT_KEY } from '../basics/docs-view-key';
import { DocEventManagerService } from './doc-event-manager.service';
import { DocCanvasPopManagerService, transformBound2OffsetBound, transformOffset2Bound } from './doc-popup-manager.service';
import { calcDocRangePositions, DocCanvasPopManagerService, transformBound2OffsetBound, transformOffset2Bound } from './doc-popup-manager.service';
import { DocFloatMenuService } from './float-menu.service';
import { DocSelectionRenderService } from './selection/doc-selection-render.service';
export type DocBlockMenuTargetKind = 'paragraph' | 'blockRange' | 'table' | 'customBlock';
@@ -63,6 +65,12 @@ export interface IDocBlockDropTarget {
};
}
export interface IDocSlashMenuRequest {
anchorRect: IBoundRectNoAngle;
nonce: number;
target: IDocBlockMenuTarget;
}
const BLOCK_RANGE_ICON_MAP: Record<string, string> = {
code: 'CodeBlockIcon',
quote: 'QuoteIcon',
@@ -102,7 +110,11 @@ export class DocParagraphMenuService extends Disposable implements IRenderModule
private readonly _activeTarget$ = new BehaviorSubject<IDocBlockMenuTarget | null>(null);
readonly activeTarget$ = this._activeTarget$.asObservable();
private readonly _slashMenuRequest$ = new BehaviorSubject<IDocSlashMenuRequest | null>(null);
readonly slashMenuRequest$ = this._slashMenuRequest$.asObservable();
private _isBlockMenuDragging = false;
private _isSlashMenuActive = false;
private _slashMenuRequestNonce = 0;
get activeParagraph() {
return this._paragrahMenu?.paragraph;
@@ -126,7 +138,8 @@ export class DocParagraphMenuService extends Disposable implements IRenderModule
@Inject(DocEventManagerService) private _docEventManagerService: DocEventManagerService,
@Inject(DocCanvasPopManagerService) private _docPopupManagerService: DocCanvasPopManagerService,
@Inject(DocSkeletonManagerService) private _docSkeletonManagerService: DocSkeletonManagerService,
@Inject(DocFloatMenuService) private _floatMenuService: DocFloatMenuService
@Inject(DocFloatMenuService) private _floatMenuService: DocFloatMenuService,
@Inject(DocSelectionRenderService) private _docSelectionRenderService: DocSelectionRenderService
) {
super();
@@ -235,6 +248,141 @@ export class DocParagraphMenuService extends Disposable implements IRenderModule
}
this.hideParagraphMenu(true);
}));
this.disposeWithMe(this._docSelectionRenderService.onKeydown$.subscribe((config) => {
if (this._isBlockMenuDragging) {
return;
}
if (this._handleSlashMenuKeydown(config)) {
return;
}
this.hideParagraphMenu(true);
}));
this.disposeWithMe(this._docSelectionRenderService.onInputBefore$.subscribe((config) => {
if (!config || this._isBlockMenuDragging) {
return;
}
this._handleSlashMenuInputBefore(config);
}));
}
private _handleSlashMenuKeydown(config: IEditorInputConfig): boolean {
if (!this._shouldOpenSlashMenu(config)) {
return false;
}
return this._openSlashMenu(config);
}
private _handleSlashMenuInputBefore(config: IEditorInputConfig): boolean {
if (!this._shouldOpenSlashMenuFromInput(config)) {
return false;
}
return this._openSlashMenu(config);
}
private _openSlashMenu(config: IEditorInputConfig): boolean {
config.event.preventDefault();
config.event.stopPropagation();
const paragraph = this._getSlashMenuParagraph(config);
if (!paragraph) {
return true;
}
this.showParagraphMenu(paragraph);
const target = this.activeTarget;
if (!target) {
return true;
}
this._slashMenuRequest$.next({
anchorRect: this._getSlashMenuAnchorRect(config, paragraph),
nonce: ++this._slashMenuRequestNonce,
target,
});
this._isSlashMenuActive = true;
return true;
}
private _shouldOpenSlashMenu(config: IEditorInputConfig): boolean {
const event = config.event as KeyboardEvent;
if (event.key !== '/' || event.altKey || event.ctrlKey || event.metaKey) {
return false;
}
return this._getCollapsedTextRange(config) != null;
}
private _shouldOpenSlashMenuFromInput(config: IEditorInputConfig): boolean {
const event = config.event as InputEvent;
const content = config.content ?? event.data ?? '';
if (content !== '/') {
return false;
}
return this._getCollapsedTextRange(config) != null;
}
private _getCollapsedTextRange(config: IEditorInputConfig): ITextRangeWithStyle | null {
const activeRange = config.activeRange ?? this._docSelectionManagerService.getActiveTextRange();
if (!activeRange) {
return null;
}
if (activeRange.collapsed || activeRange.startOffset === activeRange.endOffset) {
return {
...activeRange,
collapsed: true,
};
}
return null;
}
private _getSlashMenuParagraph(config: IEditorInputConfig): IMutiPageParagraphBound | null {
const activeRange = this._getCollapsedTextRange(config);
const offset = activeRange?.startOffset;
const activeParagraph = this._paragrahMenu?.paragraph ?? null;
if (activeParagraph && offset != null && offset >= activeParagraph.paragraphStart && offset <= activeParagraph.paragraphEnd) {
return activeParagraph;
}
if (activeParagraph && offset == null) {
return activeParagraph;
}
if (offset == null) {
return null;
}
return [...this._docEventManagerService.paragraphBounds.values()]
.find((paragraph) => offset >= paragraph.paragraphStart && offset <= paragraph.paragraphEnd)
?? null;
}
private _getSlashMenuAnchorRect(config: IEditorInputConfig, paragraph: IMutiPageParagraphBound): IBoundRectNoAngle {
const activeRange = this._getCollapsedTextRange(config);
if (!activeRange) {
return paragraph.firstLine;
}
try {
return calcDocRangePositions({
...activeRange,
collapsed: true,
endOffset: activeRange.startOffset,
}, this._context as never)?.[0] ?? paragraph.firstLine;
} catch {
// Unit tests and lightweight render contexts may not include the full skeleton/render stack.
return paragraph.firstLine;
}
}
showParagraphMenu(paragraph: IMutiPageParagraphBound) {
@@ -271,6 +419,11 @@ export class DocParagraphMenuService extends Disposable implements IRenderModule
componentKey: 'doc.paragraph.menu',
direction: 'left-center',
onClickOutside: () => {
if (this._isSlashMenuActive) {
this.hideParagraphMenu(true);
return;
}
this._docSelectionManagerService.textSelection$.pipe(first()).subscribe(() => {
if (!this._isCursorInActiveParagraph()) {
this.hideParagraphMenu(true);
@@ -424,6 +577,8 @@ export class DocParagraphMenuService extends Disposable implements IRenderModule
if (this._paragrahMenu && ((this._paragrahMenu.disposable.canDispose() || force))) {
this._paragrahMenu.disposable.dispose();
this._paragrahMenu = null;
this._isSlashMenuActive = false;
this._slashMenuRequest$.next(null);
this._activeTarget$.next(null);
}
}
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Direction } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getNextWordBoundaryOffset, getWordBoundaryByIndex } from '../word-boundary';
describe('word boundary helpers', () => {
it('finds the word under an English character', () => {
expect(getWordBoundaryByIndex('hello world', 1, 10)).toEqual({
startOffset: 10,
endOffset: 15,
});
});
it('finds the word under a Chinese character using the same Segmenter path as double click', () => {
expect(getWordBoundaryByIndex('中文测试', 1, 20)).toEqual({
startOffset: 20,
endOffset: 22,
});
});
it('ignores punctuation and whitespace for direct word lookup', () => {
expect(getWordBoundaryByIndex('hello, world', 5, 0)).toBeNull();
expect(getWordBoundaryByIndex('hello world', 5, 0)).toBeNull();
});
it('moves to previous and next English word boundaries', () => {
expect(getNextWordBoundaryOffset('hello world', 8, 10, Direction.LEFT)).toBe(16);
expect(getNextWordBoundaryOffset('hello world', 1, 10, Direction.RIGHT)).toBe(15);
expect(getNextWordBoundaryOffset('hello world', 5, 10, Direction.RIGHT)).toBe(21);
});
it('moves to previous and next Chinese word boundaries', () => {
expect(getNextWordBoundaryOffset('中文测试', 3, 20, Direction.LEFT)).toBe(22);
expect(getNextWordBoundaryOffset('中文测试', 1, 20, Direction.RIGHT)).toBe(22);
expect(getNextWordBoundaryOffset('中文测试', 2, 20, Direction.RIGHT)).toBe(24);
});
});
@@ -50,6 +50,7 @@ import {
serializeTextRange,
} from './selection-utils';
import { TextRange } from './text-range';
import { getWordBoundaryByIndex } from './word-boundary';
export interface IEditorInputConfig {
event: Event | CompositionEvent | KeyboardEvent;
@@ -420,30 +421,15 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
return;
}
// Create a locale-specific word segmenter
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
const segments = segmenter.segment(content);
const wordBoundary = getWordBoundaryByIndex(content, nodeIndex, st);
let startOffset = Number.NEGATIVE_INFINITY;
let endOffset = Number.NEGATIVE_INFINITY;
// Use that for segmentation
for (const { segment, index, isWordLike } of segments) {
if (index <= nodeIndex && nodeIndex < index + segment.length && isWordLike) {
startOffset = index + st;
endOffset = index + st + segment.length;
break;
}
}
if (Number.isFinite(startOffset) && Number.isFinite(endOffset)) {
if (wordBoundary != null) {
this.removeAllRanges();
const textRanges = [
{
startOffset,
endOffset,
startOffset: wordBoundary.startOffset,
endOffset: wordBoundary.endOffset,
},
];
@@ -0,0 +1,90 @@
/**
* 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 { Direction } from '@univerjs/core';
export interface IWordBoundary {
startOffset: number;
endOffset: number;
}
interface IWordRange {
start: number;
end: number;
}
function getWordRanges(content: string): IWordRange[] {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
return Array.from(segmenter.segment(content))
.filter((item) => item.isWordLike)
.map((item) => ({
start: item.index,
end: item.index + item.segment.length,
}));
}
export function getWordBoundaryByIndex(
content: string,
nodeIndex: number,
paragraphStartOffset: number
): IWordBoundary | null {
if (nodeIndex < 0) {
return null;
}
const range = getWordRanges(content).find((range) => range.start <= nodeIndex && nodeIndex < range.end);
if (range == null) {
return null;
}
return {
startOffset: paragraphStartOffset + range.start,
endOffset: paragraphStartOffset + range.end,
};
}
export function getNextWordBoundaryOffset(
content: string,
nodeIndex: number,
paragraphStartOffset: number,
direction: Direction
): number | null {
if (nodeIndex < 0) {
return null;
}
const ranges = getWordRanges(content);
if (direction === Direction.LEFT) {
for (let i = ranges.length - 1; i >= 0; i--) {
const range = ranges[i];
if (range.start < nodeIndex) {
return paragraphStartOffset + range.start;
}
}
return paragraphStartOffset;
}
if (direction === Direction.RIGHT) {
const target = ranges.find((range) => range.end > nodeIndex);
return paragraphStartOffset + (target?.end ?? content.length);
}
return null;
}
@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KeyCode, MetaKeys } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { BreakLineCommand } from '../../commands/commands/break-line.command';
import { SoftBreakLineShortcut } from '../core-editing.shortcut';
describe('docs core editing shortcuts', () => {
it('registers Shift+Enter as a soft line break', () => {
expect(SoftBreakLineShortcut).toMatchObject({
id: BreakLineCommand.id,
binding: KeyCode.ENTER | MetaKeys.SHIFT,
});
});
});
@@ -0,0 +1,117 @@
/**
* 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 { Direction } from '@univerjs/core';
import { KeyCode, MetaKeys } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { MoveCursorOperation, MoveSelectionOperation } from '../../commands/operations/doc-cursor.operation';
import {
MoveCursorDocumentEndShortcut,
MoveCursorDocumentStartShortcut,
MoveCursorLineEndShortcut,
MoveCursorLineStartShortcut,
MoveCursorWordLeftShortcut,
MoveCursorWordRightShortcut,
MoveSelectionDocumentEndShortcut,
MoveSelectionDocumentStartShortcut,
MoveSelectionLineEndShortcut,
MoveSelectionLineStartShortcut,
MoveSelectionWordLeftShortcut,
MoveSelectionWordRightShortcut,
} from '../cursor.shortcut';
describe('docs cursor shortcuts', () => {
it('registers line boundary movement shortcuts with platform-specific bindings', () => {
expect(MoveCursorLineStartShortcut).toMatchObject({
id: MoveCursorOperation.id,
binding: KeyCode.HOME,
mac: KeyCode.ARROW_LEFT | MetaKeys.CTRL_COMMAND,
staticParameters: { direction: Direction.LEFT, granularity: 'line' },
});
expect(MoveCursorLineEndShortcut).toMatchObject({
id: MoveCursorOperation.id,
binding: KeyCode.END,
mac: KeyCode.ARROW_RIGHT | MetaKeys.CTRL_COMMAND,
staticParameters: { direction: Direction.RIGHT, granularity: 'line' },
});
expect(MoveSelectionLineStartShortcut).toMatchObject({
id: MoveSelectionOperation.id,
binding: KeyCode.HOME | MetaKeys.SHIFT,
mac: KeyCode.ARROW_LEFT | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
staticParameters: { direction: Direction.LEFT, granularity: 'line' },
});
expect(MoveSelectionLineEndShortcut).toMatchObject({
id: MoveSelectionOperation.id,
binding: KeyCode.END | MetaKeys.SHIFT,
mac: KeyCode.ARROW_RIGHT | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
staticParameters: { direction: Direction.RIGHT, granularity: 'line' },
});
});
it('registers document boundary movement shortcuts', () => {
expect(MoveCursorDocumentStartShortcut).toMatchObject({
id: MoveCursorOperation.id,
binding: KeyCode.HOME | MetaKeys.CTRL_COMMAND,
mac: KeyCode.ARROW_UP | MetaKeys.CTRL_COMMAND,
staticParameters: { direction: Direction.UP, granularity: 'document' },
});
expect(MoveCursorDocumentEndShortcut).toMatchObject({
id: MoveCursorOperation.id,
binding: KeyCode.END | MetaKeys.CTRL_COMMAND,
mac: KeyCode.ARROW_DOWN | MetaKeys.CTRL_COMMAND,
staticParameters: { direction: Direction.DOWN, granularity: 'document' },
});
expect(MoveSelectionDocumentStartShortcut).toMatchObject({
id: MoveSelectionOperation.id,
binding: KeyCode.HOME | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
mac: KeyCode.ARROW_UP | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
staticParameters: { direction: Direction.UP, granularity: 'document' },
});
expect(MoveSelectionDocumentEndShortcut).toMatchObject({
id: MoveSelectionOperation.id,
binding: KeyCode.END | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
mac: KeyCode.ARROW_DOWN | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
staticParameters: { direction: Direction.DOWN, granularity: 'document' },
});
});
it('registers word movement shortcuts with Option on macOS and Ctrl elsewhere', () => {
expect(MoveCursorWordLeftShortcut).toMatchObject({
id: MoveCursorOperation.id,
binding: KeyCode.ARROW_LEFT | MetaKeys.CTRL_COMMAND,
mac: KeyCode.ARROW_LEFT | MetaKeys.ALT,
staticParameters: { direction: Direction.LEFT, granularity: 'word' },
});
expect(MoveCursorWordRightShortcut).toMatchObject({
id: MoveCursorOperation.id,
binding: KeyCode.ARROW_RIGHT | MetaKeys.CTRL_COMMAND,
mac: KeyCode.ARROW_RIGHT | MetaKeys.ALT,
staticParameters: { direction: Direction.RIGHT, granularity: 'word' },
});
expect(MoveSelectionWordLeftShortcut).toMatchObject({
id: MoveSelectionOperation.id,
binding: KeyCode.ARROW_LEFT | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
mac: KeyCode.ARROW_LEFT | MetaKeys.ALT | MetaKeys.SHIFT,
staticParameters: { direction: Direction.LEFT, granularity: 'word' },
});
expect(MoveSelectionWordRightShortcut).toMatchObject({
id: MoveSelectionOperation.id,
binding: KeyCode.ARROW_RIGHT | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
mac: KeyCode.ARROW_RIGHT | MetaKeys.ALT | MetaKeys.SHIFT,
staticParameters: { direction: Direction.RIGHT, granularity: 'word' },
});
});
});
@@ -0,0 +1,53 @@
/**
* 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 { KeyCode, MetaKeys } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand } from '../../commands/commands/set-heading.command';
import { H1HeadingShortcut, H2HeadingShortcut, H3HeadingShortcut, H4HeadingShortcut, H5HeadingShortcut, NormalTextHeadingShortcut } from '../heading.shortcut';
describe('docs heading shortcuts', () => {
it('registers normal text and heading shortcuts with Ctrl/Cmd+Alt/Option', () => {
const modifier = MetaKeys.CTRL_COMMAND | MetaKeys.ALT;
expect(NormalTextHeadingShortcut).toMatchObject({
id: NormalTextHeadingCommand.id,
binding: KeyCode.Digit0 | modifier,
mac: KeyCode.Digit0 | modifier,
});
expect(H1HeadingShortcut).toMatchObject({
id: H1HeadingCommand.id,
binding: KeyCode.Digit1 | modifier,
mac: KeyCode.Digit1 | modifier,
});
expect(H2HeadingShortcut).toMatchObject({
id: H2HeadingCommand.id,
binding: KeyCode.Digit2 | modifier,
});
expect(H3HeadingShortcut).toMatchObject({
id: H3HeadingCommand.id,
binding: KeyCode.Digit3 | modifier,
});
expect(H4HeadingShortcut).toMatchObject({
id: H4HeadingCommand.id,
binding: KeyCode.Digit4 | modifier,
});
expect(H5HeadingShortcut).toMatchObject({
id: H5HeadingCommand.id,
binding: KeyCode.Digit5 | modifier,
});
});
});
@@ -15,8 +15,9 @@
*/
import type { IShortcutItem } from '@univerjs/ui';
import { KeyCode } from '@univerjs/ui';
import { KeyCode, MetaKeys } from '@univerjs/ui';
import { EnterCommand } from '../commands/commands/auto-format.command';
import { BreakLineCommand } from '../commands/commands/break-line.command';
import { DeleteLeftCommand, DeleteRightCommand } from '../commands/commands/doc-delete.command';
import { whenDocAndEditorFocused, whenDocAndEditorFocusedWithBreakLine } from './utils';
@@ -26,6 +27,12 @@ export const BreakLineShortcut: IShortcutItem = {
binding: KeyCode.ENTER,
};
export const SoftBreakLineShortcut: IShortcutItem = {
id: BreakLineCommand.id,
preconditions: whenDocAndEditorFocusedWithBreakLine,
binding: KeyCode.ENTER | MetaKeys.SHIFT,
};
export const DeleteLeftShortcut: IShortcutItem = {
id: DeleteLeftCommand.id,
preconditions: whenDocAndEditorFocused,
@@ -21,6 +21,50 @@ import { DocSelectAllCommand } from '../commands/commands/doc-select-all.command
import { MoveCursorOperation, MoveSelectionOperation } from '../commands/operations/doc-cursor.operation';
import { whenDocAndEditorFocused } from './utils';
function moveCursorShortcut(
direction: Direction,
granularity: 'word' | 'line' | 'document',
binding: number,
mac: number = binding,
win: number = binding,
linux: number = binding
): IShortcutItem {
return {
id: MoveCursorOperation.id,
binding,
mac,
win,
linux,
preconditions: whenDocAndEditorFocused,
staticParameters: {
direction,
granularity,
},
};
}
function moveSelectionShortcut(
direction: Direction,
granularity: 'word' | 'line' | 'document',
binding: number,
mac: number = binding,
win: number = binding,
linux: number = binding
): IShortcutItem {
return {
id: MoveSelectionOperation.id,
binding,
mac,
win,
linux,
preconditions: whenDocAndEditorFocused,
staticParameters: {
direction,
granularity,
},
};
}
export const MoveCursorUpShortcut: IShortcutItem = {
id: MoveCursorOperation.id,
binding: KeyCode.ARROW_UP,
@@ -93,6 +137,90 @@ export const MoveSelectionRightShortcut: IShortcutItem = {
},
};
export const MoveCursorLineStartShortcut = moveCursorShortcut(
Direction.LEFT,
'line',
KeyCode.HOME,
KeyCode.ARROW_LEFT | MetaKeys.CTRL_COMMAND
);
export const MoveCursorLineEndShortcut = moveCursorShortcut(
Direction.RIGHT,
'line',
KeyCode.END,
KeyCode.ARROW_RIGHT | MetaKeys.CTRL_COMMAND
);
export const MoveSelectionLineStartShortcut = moveSelectionShortcut(
Direction.LEFT,
'line',
KeyCode.HOME | MetaKeys.SHIFT,
KeyCode.ARROW_LEFT | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT
);
export const MoveSelectionLineEndShortcut = moveSelectionShortcut(
Direction.RIGHT,
'line',
KeyCode.END | MetaKeys.SHIFT,
KeyCode.ARROW_RIGHT | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT
);
export const MoveCursorDocumentStartShortcut = moveCursorShortcut(
Direction.UP,
'document',
KeyCode.HOME | MetaKeys.CTRL_COMMAND,
KeyCode.ARROW_UP | MetaKeys.CTRL_COMMAND
);
export const MoveCursorDocumentEndShortcut = moveCursorShortcut(
Direction.DOWN,
'document',
KeyCode.END | MetaKeys.CTRL_COMMAND,
KeyCode.ARROW_DOWN | MetaKeys.CTRL_COMMAND
);
export const MoveSelectionDocumentStartShortcut = moveSelectionShortcut(
Direction.UP,
'document',
KeyCode.HOME | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
KeyCode.ARROW_UP | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT
);
export const MoveSelectionDocumentEndShortcut = moveSelectionShortcut(
Direction.DOWN,
'document',
KeyCode.END | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
KeyCode.ARROW_DOWN | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT
);
export const MoveCursorWordLeftShortcut = moveCursorShortcut(
Direction.LEFT,
'word',
KeyCode.ARROW_LEFT | MetaKeys.CTRL_COMMAND,
KeyCode.ARROW_LEFT | MetaKeys.ALT
);
export const MoveCursorWordRightShortcut = moveCursorShortcut(
Direction.RIGHT,
'word',
KeyCode.ARROW_RIGHT | MetaKeys.CTRL_COMMAND,
KeyCode.ARROW_RIGHT | MetaKeys.ALT
);
export const MoveSelectionWordLeftShortcut = moveSelectionShortcut(
Direction.LEFT,
'word',
KeyCode.ARROW_LEFT | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
KeyCode.ARROW_LEFT | MetaKeys.ALT | MetaKeys.SHIFT
);
export const MoveSelectionWordRightShortcut = moveSelectionShortcut(
Direction.RIGHT,
'word',
KeyCode.ARROW_RIGHT | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
KeyCode.ARROW_RIGHT | MetaKeys.ALT | MetaKeys.SHIFT
);
export const SelectAllShortcut: IShortcutItem = {
id: DocSelectAllCommand.id,
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
@@ -0,0 +1,40 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IShortcutItem } from '@univerjs/ui';
import { KeyCode, MetaKeys } from '@univerjs/ui';
import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand } from '../commands/commands/set-heading.command';
import { whenDocAndEditorFocused } from './utils';
function headingShortcut(id: string, key: KeyCode): IShortcutItem {
const binding = key | MetaKeys.CTRL_COMMAND | MetaKeys.ALT;
return {
id,
binding,
mac: binding,
win: binding,
linux: binding,
preconditions: whenDocAndEditorFocused,
};
}
export const NormalTextHeadingShortcut = headingShortcut(NormalTextHeadingCommand.id, KeyCode.Digit0);
export const H1HeadingShortcut = headingShortcut(H1HeadingCommand.id, KeyCode.Digit1);
export const H2HeadingShortcut = headingShortcut(H2HeadingCommand.id, KeyCode.Digit2);
export const H3HeadingShortcut = headingShortcut(H3HeadingCommand.id, KeyCode.Digit3);
export const H4HeadingShortcut = headingShortcut(H4HeadingCommand.id, KeyCode.Digit4);
export const H5HeadingShortcut = headingShortcut(H5HeadingCommand.id, KeyCode.Digit5);
@@ -67,8 +67,8 @@ export const useLeftAndRightArrow = (isNeed: boolean, selectingMode: boolean, ed
id: operationId,
type: CommandType.OPERATION,
handler(_event, params) {
const { keyCode } = params as { eventType: DeviceInputEventType; keyCode: KeyCode };
handleMoveInEditor(keyCode);
const { keyCode, metaKey } = params as { eventType: DeviceInputEventType; keyCode: KeyCode; metaKey?: MetaKeys };
handleMoveInEditor(keyCode, metaKey);
},
}));
@@ -81,14 +81,6 @@ export const useLeftAndRightArrow = (isNeed: boolean, selectingMode: boolean, ed
{ keyCode: KeyCode.ARROW_LEFT, metaKey: MetaKeys.SHIFT },
{ keyCode: KeyCode.ARROW_RIGHT, metaKey: MetaKeys.SHIFT },
{ keyCode: KeyCode.ARROW_UP, metaKey: MetaKeys.SHIFT },
{ keyCode: KeyCode.ARROW_DOWN, metaKey: MetaKeys.CTRL_COMMAND },
{ keyCode: KeyCode.ARROW_LEFT, metaKey: MetaKeys.CTRL_COMMAND },
{ keyCode: KeyCode.ARROW_RIGHT, metaKey: MetaKeys.CTRL_COMMAND },
{ keyCode: KeyCode.ARROW_UP, metaKey: MetaKeys.CTRL_COMMAND },
{ keyCode: KeyCode.ARROW_DOWN, metaKey: MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT },
{ keyCode: KeyCode.ARROW_LEFT, metaKey: MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT },
{ keyCode: KeyCode.ARROW_RIGHT, metaKey: MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT },
{ keyCode: KeyCode.ARROW_UP, metaKey: MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT },
];
keyCodes.map(({ keyCode, metaKey }) => {
@@ -100,6 +92,7 @@ export const useLeftAndRightArrow = (isNeed: boolean, selectingMode: boolean, ed
staticParameters: {
eventType: DeviceInputEventType.Keyboard,
keyCode,
metaKey,
},
};
}).forEach((item) => {
@@ -37,9 +37,10 @@ export interface ITinyMenuGroupProps {
columns?: number;
sizeVariant?: TinyMenuSizeVariant;
layoutVariant?: TinyMenuLayoutVariant;
hoverSuppressed?: boolean;
}
export function DesignTinyMenuGroup({ items, columns, sizeVariant = 'default', layoutVariant = 'default' }: ITinyMenuGroupProps) {
export function DesignTinyMenuGroup({ items, columns, sizeVariant = 'default', layoutVariant = 'default', hoverSuppressed = false }: ITinyMenuGroupProps) {
const isParagraphTVariant = sizeVariant === 'paragraph-t';
const isCompactParagraphVariant = isParagraphTVariant && layoutVariant === 'compact';
@@ -65,12 +66,21 @@ export function DesignTinyMenuGroup({ items, columns, sizeVariant = 'default', l
: undefined}
>
{items.map((item) => {
const showTooltip = !isParagraphTVariant && item.tooltip;
const ele = (
<div
<button
key={item.key}
type="button"
aria-label={item.tooltip ?? item.key}
title={showTooltip ? item.tooltip : undefined}
className={clsx(
`
univer-flex univer-cursor-pointer univer-items-center univer-justify-center
univer-border-none univer-bg-transparent univer-p-0
focus:univer-bg-gray-50 focus:univer-outline-none
dark:focus:!univer-bg-gray-900
`,
!hoverSuppressed && `
hover:univer-bg-gray-50
dark:hover:!univer-bg-gray-900
`,
@@ -101,9 +111,9 @@ export function DesignTinyMenuGroup({ items, columns, sizeVariant = 'default', l
)}
extend={ICON_EXTEND}
/>
</div>
</button>
);
return item.tooltip
return showTooltip
? (
<Tooltip key={item.key} title={item.tooltip}>
{ele}
@@ -29,6 +29,7 @@ interface IUIQuickMenuGroupProps {
item: IMenuSchema;
activeItemIds?: string[];
hiddenItemIds?: string[];
hoverSuppressed?: boolean;
columns?: number;
sizeVariant?: TinyMenuSizeVariant;
layoutVariant?: TinyMenuLayoutVariant;
@@ -135,7 +136,7 @@ function QuickTileMenuItem(props: IUIQuickTileMenuItemProps) {
}
export function UITinyMenuGroup(props: IUIQuickMenuGroupProps) {
const { item, activeItemIds, hiddenItemIds = EMPTY_HIDDEN_ITEM_IDS, columns, sizeVariant = 'default', layoutVariant = 'default', onOptionSelect } = props;
const { item, activeItemIds, hiddenItemIds = EMPTY_HIDDEN_ITEM_IDS, hoverSuppressed, columns, sizeVariant = 'default', layoutVariant = 'default', onOptionSelect } = props;
const [activeItems, setActiveItems] = useState<string[]>([]);
const [hiddenItems, setHiddenItems] = useState<string[]>([]);
const componentManager = useDependency(ComponentManager);
@@ -192,12 +193,15 @@ export function UITinyMenuGroup(props: IUIQuickMenuGroupProps) {
if (!item.children) return null;
return (
<DesignTinyMenuGroup
columns={columns}
sizeVariant={sizeVariant}
layoutVariant={layoutVariant}
items={visibleChildren.map((child) => ({
const items = visibleChildren
.map((child) => {
const Icon = child.item?.icon ? componentManager.get(child.item.icon as string) : undefined;
if (!Icon) {
return null;
}
return {
key: child.key,
onClick: () => {
onOptionSelect?.({
@@ -213,10 +217,20 @@ export function UITinyMenuGroup(props: IUIQuickMenuGroupProps) {
iconClassName: child.item?.icon === 'TextTypeIcon'
? (sizeVariant === 'paragraph-t' ? '!univer-size-4' : '!univer-size-3.5')
: undefined,
Icon: componentManager.get(child.item!.icon as string)!,
Icon,
active: resolveMenuItemActiveState(child.item?.id, activeItems.includes(child.item?.id ?? ''), activeItemIds),
tooltip: child.item?.tooltip ? localeService.t(child.item.tooltip) : undefined,
}))}
};
})
.filter((child): child is NonNullable<typeof child> => child != null);
return (
<DesignTinyMenuGroup
columns={columns}
sizeVariant={sizeVariant}
layoutVariant={layoutVariant}
hoverSuppressed={hoverSuppressed}
items={items}
/>
);
}
@@ -14,37 +14,55 @@
* limitations under the License.
*/
import { render } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { DesignTinyMenuGroup } from '../DesignTinyMenuGroup';
describe('DesignTinyMenuGroup', () => {
it('uses a tighter compact footprint for paragraph T color swatches', () => {
const Icon = ({ className }: { className?: string }) => React.createElement('span', { 'data-testid': 'swatch-icon', className });
it('renders quick icon items as focusable menu buttons', () => {
const onClick = vi.fn();
const Icon = () => <span data-testid="quick-icon" />;
render(
<DesignTinyMenuGroup
items={[{
key: 'h1',
Icon,
className: '',
onClick,
tooltip: 'Heading 1',
}]}
columns={6}
sizeVariant="paragraph-t"
/>
);
const button = screen.getByRole('button', { name: 'Heading 1' });
button.focus();
fireEvent.click(button);
expect(document.activeElement).toBe(button);
expect(button.getAttribute('title')).toBeNull();
expect(onClick).toHaveBeenCalledTimes(1);
});
it('keeps native titles for default tiny menus', () => {
const Icon = () => <span data-testid="quick-icon" />;
const { container } = render(
<DesignTinyMenuGroup
columns={8}
sizeVariant="paragraph-t"
layoutVariant="compact"
items={[{
key: 'swatch',
onClick: () => {},
className: '',
key: 'h1',
Icon,
className: '',
onClick: vi.fn(),
tooltip: 'Heading 1',
}]}
/>
);
const group = container.firstChild as HTMLDivElement | null;
const button = group?.querySelector('div');
const icon = group?.querySelector('[data-testid="swatch-icon"]');
expect(group?.className ?? '').toContain('univer-gap-0.5');
expect(group?.className ?? '').toContain('univer-p-0');
expect(button?.className ?? '').toContain('univer-size-6');
expect(button?.className ?? '').toContain('univer-rounded-sm');
expect(icon?.className ?? '').toContain('univer-size-5');
expect(container.querySelector('button')?.getAttribute('title')).toBe('Heading 1');
});
it('uses the primary color channel for two-channel icons', () => {
@@ -177,4 +177,47 @@ describe('TinyMenuGroup', () => {
expect(props.sizeVariant).toBe('paragraph-t');
});
it('skips tiny menu children whose icons are not registered', () => {
dependencyMap.set(ComponentManager, {
get: (key: string) => key === 'KnownIcon' ? () => React.createElement('span') : undefined,
});
const item = {
key: 'quick',
order: 0,
children: [
{
key: 'known',
order: 0,
item: {
id: 'known',
type: MenuItemType.BUTTON,
icon: 'KnownIcon',
hidden$: of(false),
activated$: of(false),
},
},
{
key: 'missing',
order: 1,
item: {
id: 'missing',
type: MenuItemType.BUTTON,
icon: 'MissingIcon',
hidden$: of(false),
activated$: of(false),
},
},
],
} as never;
render(React.createElement(UITinyMenuGroup, { item }));
const props = designTinyMenuGroupSpy.mock.calls[0][0] as {
items: Array<{ key: string }>;
};
expect(props.items.map((menuItem) => menuItem.key)).toEqual(['known']);
});
});
+4
View File
@@ -1,2 +1,6 @@
@tailwind components;
@tailwind utilities;
.univer-context-menu-hover-suppressed button:hover:not(:focus) {
background-color: transparent !important;
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { IMenuButtonItem } from '../menu/menu';
import type { IMenuButtonItem, IMenuItem } from '../menu/menu';
import { BehaviorSubject, Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CONTEXT_MENU_SUBMENU_CLOSE_DELAY, hasRenderableContextMenuSchema } from '../../views/components/context-menu/ContextMenuPanel';
@@ -148,6 +148,58 @@ describe('MenuManagerService', () => {
]);
});
it('replaces an existing menu node without preserving stale children', () => {
const service = new MenuManagerService({ invoke: vi.fn((factory: any) => factory({})) } as any, { getConfig: vi.fn() } as any);
service.mergeMenu({
[ContextMenuPosition.PARAGRAPH]: {
shapeMenu: {
order: 1,
child: {
order: 0,
menuItemFactory: () => ({
id: 'shape-child',
type: MenuItemType.BUTTON,
} as IMenuButtonItem),
},
menuItemFactory: () => ({
id: 'shapeMenu',
type: MenuItemType.SUBITEMS,
} as IMenuItem),
},
},
});
service.mergeMenu({
[ContextMenuPosition.PARAGRAPH]: {
shapeMenu: {
replace: true,
order: 1,
menuItemFactory: () => ({
id: 'shapeMenu',
type: MenuItemType.SELECTOR,
} as IMenuItem),
},
},
});
service.mergeMenu({
[ContextMenuPosition.PARAGRAPH]: {
shapeMenu: {
childFromLateMerge: {
order: 0,
menuItemFactory: () => ({
id: 'late-shape-child',
type: MenuItemType.BUTTON,
} as IMenuButtonItem),
},
},
},
});
const shapeMenu = service.getMenuByPositionKey(ContextMenuPosition.PARAGRAPH).find((item) => item.key === 'shapeMenu');
expect(shapeMenu?.item?.type).toBe(MenuItemType.SELECTOR);
expect(shapeMenu?.children).toBeUndefined();
});
it('ignores nested context menu containers that have no direct renderable items', () => {
expect(hasRenderableContextMenuSchema({
key: 'emptyParagraph',
@@ -55,6 +55,7 @@ export interface IMenuManagerService {
export type MenuSchemaType = {
order?: number;
replace?: boolean;
menuItemFactory?: (accessor: IAccessor) => IMenuItem;
headerActionMenuItemFactory?: (accessor: IAccessor) => IMenuItem;
title?: string;
@@ -276,12 +277,13 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic
for (const [key, value] of Object.entries(_target)) {
if (key in source) {
const _key = key as keyof MenuSchemaType;
_target[_key] = merge({}, _target[_key], source[_key]);
const targetRecord = _target as Record<string, unknown>;
const sourceRecord = source as Record<string, unknown>;
targetRecord[key] = mergeMenuSchemaNode(targetRecord[key], sourceRecord[key]);
this.menuChanged$.next();
} else if (typeof value === 'object') {
this.mergeMenu(source, value);
} else if (isMenuSchemaRecord(value)) {
this.mergeMenu(source, value as MenuSchemaType);
}
}
}
@@ -295,6 +297,10 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic
const result: IMenuSchema[] = [];
for (const [key, value] of Object.entries(data)) {
if (key === 'replace') {
continue;
}
const menuItem: Partial<IMenuSchema> = {
key,
order: value.order,
@@ -389,3 +395,51 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic
function normalizeMenuOrder(order: number | undefined): number {
return order ?? 0;
}
function isMenuSchemaRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
function cloneMenuSchemaNode<T>(source: T, preserveReplace = false): T {
if (!isMenuSchemaRecord(source)) {
return source;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(source)) {
if (key === 'replace' && !preserveReplace) {
continue;
}
result[key] = cloneMenuSchemaNode(value, preserveReplace);
}
return result as T;
}
function mergeMenuSchemaNode(target: unknown, source: unknown): unknown {
if (!isMenuSchemaRecord(source) || !isMenuSchemaRecord(target)) {
return cloneMenuSchemaNode(source);
}
if (source.replace === true) {
return cloneMenuSchemaNode(source, true);
}
if (target.replace === true) {
return cloneMenuSchemaNode(target, true);
}
const result = merge({}, target) as Record<string, unknown>;
for (const [key, value] of Object.entries(source)) {
if (key === 'replace') {
continue;
}
result[key] = key in result
? mergeMenuSchemaNode(result[key], value)
: cloneMenuSchemaNode(value, true);
}
return result;
}
@@ -24,6 +24,8 @@ describe('keycode mappings', () => {
expect(KeyCodeToChar[KeyCode.DELETE]).toBe('Del');
expect(KeyCodeToChar[KeyCode.ESC]).toBe('Esc');
expect(KeyCodeToChar[KeyCode.SPACE]).toBe('Space');
expect(KeyCodeToChar[KeyCode.HOME]).toBe('Home');
expect(KeyCodeToChar[KeyCode.END]).toBe('End');
});
it('should map alphanumeric and function keys', () => {
@@ -31,6 +31,8 @@ export enum KeyCode {
ESC = 27,
SPACE = 32,
END = 35,
HOME = 36,
ARROW_LEFT = 37,
ARROW_UP = 38,
ARROW_RIGHT = 39,
@@ -107,6 +109,8 @@ export const KeyCodeToChar: { [key: number]: string } = {
[KeyCode.DELETE]: 'Del',
[KeyCode.ESC]: 'Esc',
[KeyCode.SPACE]: 'Space',
[KeyCode.HOME]: 'Home',
[KeyCode.END]: 'End',
[KeyCode.ARROW_LEFT]: '←',
[KeyCode.ARROW_RIGHT]: '→',
[KeyCode.ARROW_UP]: '↑',
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Dispatch, SetStateAction } from 'react';
import type { Dispatch, KeyboardEvent, SetStateAction } from 'react';
import type {
IDisplayMenuItem,
IMenuButtonItem,
@@ -39,6 +39,7 @@ import { IMenuManagerService } from '../../../services/menu/menu-manager.service
import { useDependency, useObservable } from '../../../utils/di';
type ContextMenuSizeVariant = 'default' | 'paragraph-t';
type ContextMenuAutoFocusTarget = 'first-item' | 'container';
interface IContextMenuPanelProps {
menuType: string;
@@ -47,6 +48,10 @@ interface IContextMenuPanelProps {
activeItemIds?: string[];
hiddenItemIds?: string[];
sizeVariant?: ContextMenuSizeVariant;
autoFocus?: boolean;
autoFocusTarget?: ContextMenuAutoFocusTarget;
suppressHoverUntilPointerMove?: boolean;
onCancel?: () => void;
onOptionSelect?: (option: IValueOption) => void;
}
@@ -57,6 +62,7 @@ interface IContextMenuMenuProps {
maxMenuHeight: number;
activeItemIds?: string[];
hiddenItemIds?: string[];
hoverSuppressed?: boolean;
sizeVariant: ContextMenuSizeVariant;
onOptionSelect?: (option: IValueOption) => void;
}
@@ -71,6 +77,7 @@ interface IContextMenuMenuItemProps {
setActiveSubmenuKey: Dispatch<SetStateAction<string | null>>;
activeItemIds?: string[];
hiddenItemIds?: string[];
hoverSuppressed?: boolean;
compact?: boolean;
headerAction?: boolean;
sizeVariant: ContextMenuSizeVariant;
@@ -90,6 +97,7 @@ export const CONTEXT_MENU_SUBMENU_CLOSE_DELAY = 500;
export const CONTEXT_MENU_SUBMENU_PORTAL_ATTR = 'data-u-context-menu-submenu';
const CONTEXT_MENU_CONNECTED_QUICK_GROUP_KEYS = new Set(['quickTop', 'quickBottom']);
const CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS = new Set(['quickTop', 'quickBottom']);
const CONTEXT_MENU_NAVIGATION_KEYS = new Set(['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight']);
type MenuLabel = IMenuItem['label'] | IValueOption['label'];
@@ -222,6 +230,131 @@ function getContextMenuQuickGroupClusterClassName(sizeVariant: ContextMenuSizeVa
return sizeVariant === 'paragraph-t' ? 'univer-grid univer-gap-0 univer-py-2' : getContextMenuGroupClassName(sizeVariant);
}
function getMenuButtonCenter(button: HTMLButtonElement) {
const rect = button.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
function getMenuButtonRows(buttons: HTMLButtonElement[]): HTMLButtonElement[][] {
const sortedButtons = [...buttons].sort((left, right) => {
const leftRect = left.getBoundingClientRect();
const rightRect = right.getBoundingClientRect();
return leftRect.top - rightRect.top || leftRect.left - rightRect.left;
});
const rows: HTMLButtonElement[][] = [];
for (const button of sortedButtons) {
const rect = button.getBoundingClientRect();
const centerY = rect.top + rect.height / 2;
const row = rows.find((candidateRow) => {
const rowRect = candidateRow[0].getBoundingClientRect();
const rowCenterY = rowRect.top + rowRect.height / 2;
const tolerance = Math.max(4, Math.max(rowRect.height, rect.height) / 2);
return Math.abs(rowCenterY - centerY) <= tolerance;
});
if (row) {
row.push(button);
} else {
rows.push([button]);
}
}
rows.forEach((row) => row.sort((left, right) => left.getBoundingClientRect().left - right.getBoundingClientRect().left));
return rows;
}
function getFirstMenuButtonByVisualOrder(buttons: HTMLButtonElement[]): HTMLButtonElement | undefined {
return [...buttons].sort((left, right) => {
const leftRect = left.getBoundingClientRect();
const rightRect = right.getBoundingClientRect();
return leftRect.top - rightRect.top || leftRect.left - rightRect.left;
})[0];
}
export function getNextMenuButtonByDirection(
buttons: HTMLButtonElement[],
activeIndex: number,
key: string
): HTMLButtonElement {
const direction = key === 'ArrowDown' || key === 'ArrowRight' ? 1 : -1;
const fallbackIndex = activeIndex < 0
? (direction > 0 ? 0 : buttons.length - 1)
: (activeIndex + direction + buttons.length) % buttons.length;
const fallbackButton = buttons[fallbackIndex];
const activeButton = activeIndex >= 0 ? buttons[activeIndex] : null;
if (!activeButton) {
return fallbackButton;
}
const rows = getMenuButtonRows(buttons);
const activeRowIndex = rows.findIndex((row) => row.includes(activeButton));
const activeRow = rows[activeRowIndex];
if (activeRow && (key === 'ArrowRight' || key === 'ArrowLeft')) {
const rowButtonIndex = activeRow.indexOf(activeButton);
const nextRowButton = activeRow[rowButtonIndex + (key === 'ArrowRight' ? 1 : -1)];
return nextRowButton ?? fallbackButton;
}
if (activeRow && (key === 'ArrowDown' || key === 'ArrowUp')) {
const nextRow = rows[activeRowIndex + (key === 'ArrowDown' ? 1 : -1)];
if (nextRow) {
const activeCenter = getMenuButtonCenter(activeButton);
return [...nextRow].sort((left, right) => (
Math.abs(getMenuButtonCenter(left).x - activeCenter.x) - Math.abs(getMenuButtonCenter(right).x - activeCenter.x)
))[0] ?? fallbackButton;
}
}
const activeCenter = getMenuButtonCenter(activeButton);
const scoredCandidates = buttons
.filter((button) => button !== activeButton)
.map((button) => {
const center = getMenuButtonCenter(button);
const deltaX = center.x - activeCenter.x;
const deltaY = center.y - activeCenter.y;
const isCandidate = key === 'ArrowRight'
? deltaX > 0
: key === 'ArrowLeft'
? deltaX < 0
: key === 'ArrowDown'
? deltaY > 0
: deltaY < 0;
if (!isCandidate) {
return null;
}
const primaryDistance = key === 'ArrowRight' || key === 'ArrowLeft'
? Math.abs(deltaX)
: Math.abs(deltaY);
const secondaryDistance = key === 'ArrowRight' || key === 'ArrowLeft'
? Math.abs(deltaY)
: Math.abs(deltaX);
return {
button,
score: primaryDistance * 1000 + secondaryDistance,
};
})
.filter((candidate): candidate is { button: HTMLButtonElement; score: number } => candidate != null)
.sort((left, right) => left.score - right.score);
return scoredCandidates[0]?.button ?? fallbackButton;
}
export function getContextMenuSchemaRenderGroups(
visibleSchemas: IMenuSchema[],
sizeVariant: ContextMenuSizeVariant
@@ -287,7 +420,19 @@ function getContextMenuSubmenuPanelClassName(sizeVariant: ContextMenuSizeVariant
}
export function ContextMenuPanel(props: IContextMenuPanelProps) {
const { menuType, menuSessionVersion = 0, className, activeItemIds, hiddenItemIds, sizeVariant = 'default', onOptionSelect } = props;
const {
menuType,
menuSessionVersion = 0,
className,
activeItemIds,
hiddenItemIds,
sizeVariant = 'default',
autoFocus,
autoFocusTarget = 'first-item',
suppressHoverUntilPointerMove = false,
onCancel,
onOptionSelect,
} = props;
const menuManagerService = useDependency(IMenuManagerService);
const layoutService = useDependency(ILayoutService);
const [menuElement, setMenuElement] = useState<HTMLDivElement | null>(null);
@@ -298,6 +443,7 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
return Math.max(120, window.innerHeight - menuViewportPadding * 2);
});
const [hoverSuppressed, setHoverSuppressed] = useState(suppressHoverUntilPointerMove);
const menuSchemaVersion$ = useMemo(
() => menuManagerService.menuChanged$.pipe(startWith(undefined), scan((version) => version + 1, 0)),
[menuManagerService]
@@ -313,6 +459,73 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
useScrollYOverContainer(menuElement, layoutService.rootContainerElement);
const getFocusableMenuButtons = useCallback(() => {
if (!menuElement) {
return [];
}
return Array.from(menuElement.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));
}, [menuElement]);
useEffect(() => {
if (!autoFocus || !menuElement) {
return;
}
const view = menuElement.ownerDocument.defaultView ?? window;
const frameId = view.requestAnimationFrame(() => {
if (autoFocusTarget === 'container') {
menuElement.focus();
return;
}
const firstButton = getFirstMenuButtonByVisualOrder(getFocusableMenuButtons());
(firstButton ?? menuElement).focus();
});
return () => view.cancelAnimationFrame(frameId);
}, [autoFocus, autoFocusTarget, getFocusableMenuButtons, menuElement, menuItems]);
useEffect(() => {
setHoverSuppressed(suppressHoverUntilPointerMove);
}, [menuSessionVersion, menuType, suppressHoverUntilPointerMove]);
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onCancel?.();
return;
}
if (!CONTEXT_MENU_NAVIGATION_KEYS.has(event.key) && event.key !== 'Enter') {
return;
}
const focusableButtons = getFocusableMenuButtons();
if (!focusableButtons.length) {
return;
}
event.preventDefault();
event.stopPropagation();
const activeElement = menuElement?.ownerDocument.activeElement;
const activeIndex = focusableButtons.findIndex((button) => button === activeElement);
if (event.key === 'Enter') {
const button = activeIndex >= 0 ? focusableButtons[activeIndex] : getFirstMenuButtonByVisualOrder(focusableButtons);
if (!button) {
return;
}
button.click();
return;
}
getNextMenuButtonByDirection(focusableButtons, activeIndex, event.key).focus();
}, [getFocusableMenuButtons, menuElement, onCancel]);
useEffect(() => {
const defaultView = layoutService.rootContainerElement?.ownerDocument?.defaultView
?? (typeof window !== 'undefined' ? window : null);
@@ -350,15 +563,23 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
return (
<div
ref={setMenuElement}
tabIndex={-1}
className={clsx(
getContextMenuPanelClassName(sizeVariant),
borderClassName,
scrollbarClassName,
hoverSuppressed && 'univer-context-menu-hover-suppressed',
className
)}
style={{
maxHeight: maxMenuHeight,
}}
onKeyDown={handleKeyDown}
onPointerMove={() => {
if (hoverSuppressed) {
setHoverSuppressed(false);
}
}}
onWheel={(event) => event.stopPropagation()}
>
<ContextMenuMenu
@@ -367,6 +588,7 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
submenuPortalContainer={submenuPortalContainer}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
hoverSuppressed={hoverSuppressed}
sizeVariant={sizeVariant}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
@@ -376,7 +598,7 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
}
function ContextMenuMenu(props: IContextMenuMenuProps) {
const { menuSchemas, menuSessionVersion, submenuPortalContainer, activeItemIds, hiddenItemIds, sizeVariant, onOptionSelect, maxMenuHeight } = props;
const { menuSchemas, menuSessionVersion, submenuPortalContainer, activeItemIds, hiddenItemIds, hoverSuppressed, sizeVariant, onOptionSelect, maxMenuHeight } = props;
const localeService = useDependency(LocaleService);
const hiddenGroupStates = useContextGroupHiddenStates(menuSchemas);
const [activeSubmenuKey, setActiveSubmenuKey] = useState<string | null>(null);
@@ -424,6 +646,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
item={menuSchema}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
hoverSuppressed={hoverSuppressed}
onOptionSelect={onOptionSelect}
/>
)
@@ -433,6 +656,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
columns={getContextMenuQuickGroupColumns(menuSchema)}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
hoverSuppressed={hoverSuppressed}
sizeVariant={sizeVariant}
layoutVariant={menuSchema.quickLayoutVariant}
onOptionSelect={onOptionSelect}
@@ -480,6 +704,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
hiddenItemIds={hiddenItemIds}
hoverSuppressed={hoverSuppressed}
sizeVariant={sizeVariant}
/>
);
@@ -516,6 +741,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
setActiveSubmenuKey={setActiveSubmenuKey}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
hoverSuppressed={hoverSuppressed}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
compact
@@ -548,6 +774,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
setActiveSubmenuKey={setActiveSubmenuKey}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
hoverSuppressed={hoverSuppressed}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
sizeVariant={sizeVariant}
@@ -589,6 +816,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
setActiveSubmenuKey={setActiveSubmenuKey}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
hoverSuppressed={hoverSuppressed}
compact
headerAction
sizeVariant={sizeVariant}
@@ -611,6 +839,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
setActiveSubmenuKey,
activeItemIds,
hiddenItemIds = [],
hoverSuppressed = false,
compact = false,
headerAction = false,
sizeVariant,
@@ -791,11 +1020,16 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
),
disabled
? 'univer-cursor-not-allowed univer-opacity-60'
: `
: !hoverSuppressed && `
univer-cursor-pointer
hover:univer-bg-gray-50
dark:hover:!univer-bg-gray-600
`,
!disabled && hoverSuppressed && 'univer-cursor-pointer',
!disabled && `
focus:univer-bg-gray-50 focus:univer-outline-none
dark:focus:!univer-bg-gray-600
`,
!disabled && !hoverSuppressed && 'dark:hover:!univer-bg-gray-600',
resolveMenuItemActiveState(menuItem.id, activated, activeItemIds) && `
univer-bg-gray-200
dark:!univer-bg-gray-600
@@ -828,7 +1062,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
className="univer-relative"
onMouseEnter={() => {
clearSubmenuCloseTimer();
if (hasSubmenu && !disabled) {
if (hasSubmenu && !disabled && !hoverSuppressed) {
setSubmenuPositionReady(false);
setActiveSubmenuKey(menuKey);
}
@@ -1069,6 +1303,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
submenuPortalContainer={submenuPortalContainer}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
hoverSuppressed={hoverSuppressed}
sizeVariant={sizeVariant}
onOptionSelect={onSubmenuOptionSelect}
maxMenuHeight={maxMenuHeight}
@@ -28,6 +28,7 @@ import {
ContextMenuPanel,
getContextMenuQuickGroupColumns,
getContextMenuSchemaRenderGroups,
getNextMenuButtonByDirection,
shouldShowContextMenuGroupSeparator,
} from '../ContextMenuPanel';
@@ -119,6 +120,147 @@ describe('ContextMenuPanel', () => {
}));
});
it('suppresses initial hover highlight until the pointer moves', () => {
dependencyMap.clear();
tinyMenuGroupSpy.mockClear();
dependencyMap.set(IMenuManagerService, {
menuChanged$: new BehaviorSubject<void>(undefined),
getMenuByPositionKey: vi.fn(() => [{
key: 'insert',
order: 0,
children: [{
key: 'table',
order: 0,
item: {
id: 'insert-table',
type: MenuItemType.BUTTON,
},
}],
}]),
});
dependencyMap.set(ILayoutService, {
rootContainerElement: document.body,
});
dependencyMap.set(LocaleService, {
t: (key: string) => key,
direction$: new BehaviorSubject<'ltr'>('ltr'),
});
const { container } = render(React.createElement(ContextMenuPanel as never, {
menuType: 'insert-menu',
suppressHoverUntilPointerMove: true,
}));
const panel = container.firstChild as HTMLDivElement;
expect(panel.className).toContain('univer-context-menu-hover-suppressed');
fireEvent.pointerMove(panel);
expect(panel.className).not.toContain('univer-context-menu-hover-suppressed');
});
it('keeps selector submenus closed while initial hover is suppressed', () => {
dependencyMap.clear();
tinyMenuGroupSpy.mockClear();
dependencyMap.set(IMenuManagerService, {
menuChanged$: new BehaviorSubject<void>(undefined),
getMenuByPositionKey: vi.fn(() => [{
key: 'insert',
order: 0,
children: [{
key: 'table',
order: 0,
item: {
id: 'insert-table',
type: MenuItemType.BUTTON_SELECTOR,
title: 'insert table',
tooltip: 'insert table',
selections: [{
label: 'table picker',
value: 'table picker',
}],
},
}],
}]),
});
dependencyMap.set(ILayoutService, {
rootContainerElement: document.body,
});
dependencyMap.set(LocaleService, {
t: (key: string) => key,
direction$: new BehaviorSubject<'ltr'>('ltr'),
});
const { container, unmount } = render(React.createElement(ContextMenuPanel as never, {
menuType: 'insert-menu',
suppressHoverUntilPointerMove: true,
}));
const panel = container.firstChild as HTMLDivElement;
const tableButton = document.querySelector('button[title="insert table"]') as HTMLButtonElement | null;
const tableWrapper = tableButton?.parentElement as HTMLDivElement | null;
expect(tableWrapper).not.toBeNull();
fireEvent.mouseEnter(tableWrapper!);
expect(document.querySelectorAll(`[${CONTEXT_MENU_SUBMENU_PORTAL_ATTR}="true"]`)).toHaveLength(0);
fireEvent.pointerMove(panel);
fireEvent.mouseEnter(tableWrapper!);
expect(document.querySelectorAll(`[${CONTEXT_MENU_SUBMENU_PORTAL_ATTR}="true"]`)).toHaveLength(1);
unmount();
});
it('can focus the menu container without selecting an item initially', async () => {
dependencyMap.clear();
tinyMenuGroupSpy.mockClear();
dependencyMap.set(IMenuManagerService, {
menuChanged$: new BehaviorSubject<void>(undefined),
getMenuByPositionKey: vi.fn(() => [{
key: 'insert',
order: 0,
children: [{
key: 'table',
order: 0,
item: {
id: 'insert-table',
type: MenuItemType.BUTTON,
title: 'insert table',
tooltip: 'insert table',
},
}],
}]),
});
dependencyMap.set(ILayoutService, {
rootContainerElement: document.body,
});
dependencyMap.set(LocaleService, {
t: (key: string) => key,
direction$: new BehaviorSubject<'ltr'>('ltr'),
});
const { container } = render(React.createElement(ContextMenuPanel as never, {
menuType: 'insert-menu',
autoFocus: true,
autoFocusTarget: 'container',
}));
const panel = container.firstChild as HTMLDivElement;
const tableButton = document.querySelector('button[title="insert table"]') as HTMLButtonElement | null;
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
expect(document.activeElement).toBe(panel);
fireEvent.keyDown(panel, { key: 'ArrowDown' });
expect(document.activeElement).toBe(tableButton);
});
it('does not render a separator between consecutive header quick rows', () => {
expect(shouldShowContextMenuGroupSeparator([
{ key: 'quickTop', order: 0, quickLayout: 'icon' },
@@ -405,6 +547,116 @@ describe('ContextMenuPanel', () => {
expect(screen.getAllByTestId('tiny-menu-group').length).toBeGreaterThan(0);
});
it('supports keyboard focus, navigation, confirm, and cancel', async () => {
dependencyMap.clear();
const onOptionSelect = vi.fn();
const onCancel = vi.fn();
dependencyMap.set(IMenuManagerService, {
menuChanged$: new BehaviorSubject<void>(undefined),
getMenuByPositionKey: vi.fn(() => [{
key: 'insert',
order: 0,
children: [
{
key: 'heading-1',
order: 0,
item: {
id: 'heading-1',
type: MenuItemType.BUTTON,
title: 'heading 1',
tooltip: 'heading 1',
},
},
{
key: 'callout',
order: 1,
item: {
id: 'callout',
type: MenuItemType.BUTTON,
title: 'callout',
tooltip: 'callout',
},
},
],
}]),
});
dependencyMap.set(ILayoutService, {
rootContainerElement: document.body,
});
dependencyMap.set(LocaleService, {
t: (key: string) => key,
direction$: new BehaviorSubject<'ltr'>('ltr'),
});
const { container } = render(
<ContextMenuPanel
menuType="keyboard-menu"
autoFocus
onCancel={onCancel}
onOptionSelect={onOptionSelect}
/>
);
const panel = container.firstElementChild as HTMLDivElement;
const headingButton = document.querySelector('button[title="heading 1"]') as HTMLButtonElement | null;
const calloutButton = document.querySelector('button[title="callout"]') as HTMLButtonElement | null;
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
expect(document.activeElement).toBe(headingButton);
fireEvent.keyDown(panel, { key: 'ArrowDown' });
expect(document.activeElement).toBe(calloutButton);
fireEvent.keyDown(panel, { key: 'ArrowLeft' });
expect(document.activeElement).toBe(headingButton);
fireEvent.keyDown(panel, { key: 'ArrowRight' });
expect(document.activeElement).toBe(calloutButton);
fireEvent.keyDown(panel, { key: 'Enter' });
expect(onOptionSelect).toHaveBeenCalledWith(expect.objectContaining({
id: 'callout',
label: 'callout',
}));
fireEvent.keyDown(panel, { key: 'Escape' });
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('keeps left and right navigation on the same visual row until the row boundary', () => {
const createButton = (left: number, top: number) => ({
getBoundingClientRect: () => ({
bottom: top + 32,
height: 32,
left,
right: left + 32,
top,
width: 32,
x: left,
y: top,
toJSON: () => ({}),
}),
}) as HTMLButtonElement;
const h2 = createButton(56, 0);
const h3 = createButton(112, 0);
const visuallyCloseSecondRowItem = createButton(60, 40);
expect(getNextMenuButtonByDirection([
h2,
h3,
visuallyCloseSecondRowItem,
], 0, 'ArrowRight')).toBe(h3);
expect(getNextMenuButtonByDirection([
h2,
h3,
visuallyCloseSecondRowItem,
], 1, 'ArrowRight')).toBe(visuallyCloseSecondRowItem);
});
it('keeps the newly hovered submenu open when switching quickly between sibling submenu items', () => {
vi.useFakeTimers();
dependencyMap.clear();