mirror of
https://github.com/dream-num/univer.git
synced 2026-09-01 15:29:43 +08:00
fix(docs): improve docs slash menu and shortcuts (#7072)
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+284
@@ -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();
|
||||
|
||||
|
||||
+298
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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: 'إعدادات الفقرة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'تنظیمات پاراگراف',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '段落設定',
|
||||
|
||||
@@ -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: '문단 설정',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Настройка абзаца',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '段落设置',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user