feat(docs): refine paragraph menus and table viewport (#7050)

This commit is contained in:
Univer
2026-06-10 21:38:34 +08:00
committed by GitHub
parent 123c1ef91e
commit 7ef01d3415
66 changed files with 4204 additions and 290 deletions
@@ -0,0 +1,94 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { InsertDocDrawingCommand } from '../insert-doc-drawing.command';
import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../insert-shape.command';
function createAccessor() {
const executeCommand = vi.fn(async () => true);
const getCurrentUnitOfType = vi.fn((type: UniverInstanceType) => {
if (type !== UniverInstanceType.UNIVER_DOC) {
return null;
}
return {
getUnitId: () => 'test-doc',
};
});
return {
executeCommand,
accessor: {
get: (token: unknown) => {
if (token === ICommandService) {
return {
executeCommand,
};
}
if (token === IUniverInstanceService) {
return {
getCurrentUnitOfType,
};
}
throw new Error(`Unexpected token: ${String(token)}`);
},
},
};
}
describe('insert shape commands', () => {
it('inserts a rectangle shape through the drawing insertion command', async () => {
const { accessor, executeCommand } = createAccessor();
await InsertDocRectangleShapeCommand.handler(accessor as never);
expect(executeCommand).toHaveBeenCalledTimes(1);
expect(executeCommand).toHaveBeenCalledWith(
InsertDocDrawingCommand.id,
expect.objectContaining({
unitId: 'test-doc',
drawings: [
expect.objectContaining({
source: expect.stringContaining('data:image/svg+xml'),
}),
],
})
);
});
it('inserts an ellipse shape through the drawing insertion command', async () => {
const { accessor, executeCommand } = createAccessor();
await InsertDocEllipseShapeCommand.handler(accessor as never);
expect(executeCommand).toHaveBeenCalledTimes(1);
expect(executeCommand).toHaveBeenCalledWith(
InsertDocDrawingCommand.id,
expect.objectContaining({
unitId: 'test-doc',
drawings: [
expect.objectContaining({
source: expect.stringContaining('data:image/svg+xml'),
}),
],
})
);
});
});
@@ -0,0 +1,121 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand, IDocDrawingPosition } from '@univerjs/core';
import type { IInsertDrawingCommandParams } from './interfaces';
import {
BooleanNumber,
CommandType,
DrawingTypeEnum,
generateRandomId,
ICommandService,
IUniverInstanceService,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
UniverInstanceType,
WrapTextType,
} from '@univerjs/core';
import { docDrawingPositionToTransform } from '@univerjs/docs-ui';
import { ImageSourceType } from '@univerjs/drawing';
import { InsertDocDrawingCommand } from './insert-doc-drawing.command';
type DocShapeKind = 'rectangle' | 'ellipse';
const DOC_SHAPE_FILL = '#3A60F7';
const DOC_SHAPE_STROKE = '#1D3EA6';
function createShapeSvgSource(shape: DocShapeKind, width: number, height: number) {
const content = shape === 'ellipse'
? `<ellipse cx="${width / 2}" cy="${height / 2}" rx="${(width - 8) / 2}" ry="${(height - 8) / 2}" fill="${DOC_SHAPE_FILL}" stroke="${DOC_SHAPE_STROKE}" stroke-width="4" />`
: `<rect x="4" y="4" width="${width - 8}" height="${height - 8}" rx="14" fill="${DOC_SHAPE_FILL}" stroke="${DOC_SHAPE_STROKE}" stroke-width="4" />`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" fill="none">${content}</svg>`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
function createDefaultDocTransform(width: number, height: number): IDocDrawingPosition {
return {
size: {
width,
height,
},
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 0,
},
positionV: {
relativeFrom: ObjectRelativeFromV.PARAGRAPH,
posOffset: 0,
},
angle: 0,
};
}
function createShapeInsertCommand(shape: DocShapeKind, width: number, height: number, id: string): ICommand {
return {
id,
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const unitId = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)?.getUnitId();
if (!unitId) {
return false;
}
const docTransform = createDefaultDocTransform(width, height);
return commandService.executeCommand(InsertDocDrawingCommand.id, {
unitId,
drawings: [{
unitId,
subUnitId: unitId,
drawingId: generateRandomId(6),
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.BASE64,
source: createShapeSvgSource(shape, width, height),
transform: docDrawingPositionToTransform(docTransform),
docTransform,
behindDoc: BooleanNumber.FALSE,
title: '',
description: '',
layoutType: PositionedObjectLayoutType.INLINE,
wrapText: WrapTextType.BOTH_SIDES,
distB: 0,
distL: 0,
distR: 0,
distT: 0,
}],
} as IInsertDrawingCommandParams);
},
};
}
export const InsertDocRectangleShapeCommand = createShapeInsertCommand(
'rectangle',
144,
96,
'doc.command.insert-float-shape.rectangle'
);
export const InsertDocEllipseShapeCommand = createShapeInsertCommand(
'ellipse',
112,
112,
'doc.command.insert-float-shape.ellipse'
);
@@ -22,6 +22,7 @@ import { GroupDocDrawingCommand } from '../commands/commands/group-doc-drawing.c
import { InsertDocDrawingCommand } from '../commands/commands/insert-doc-drawing.command';
import { InsertDocImageCommand } from '../commands/commands/insert-image.command';
import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../commands/commands/insert-shape.command';
import { MoveDocDrawingsCommand } from '../commands/commands/move-drawings.command';
import { RemoveDocDrawingCommand } from '../commands/commands/remove-doc-drawing.command';
import { SetDocDrawingArrangeCommand } from '../commands/commands/set-drawing-arrange.command';
@@ -59,6 +60,8 @@ export class DocDrawingUIController extends Disposable {
private _initCommands() {
[
InsertDocImageCommand,
InsertDocRectangleShapeCommand,
InsertDocEllipseShapeCommand,
InsertDocDrawingCommand,
UpdateDocDrawingWrappingStyleCommand,
UpdateDocDrawingDistanceCommand,
@@ -16,7 +16,7 @@
import type { DocumentDataModel, ICommandInfo, IDrawingParam, ITransformState } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { Documents, DocumentSkeleton, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, Image, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { Documents, DocumentSkeleton, IDocsTableRenderViewport, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, Image, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import {
BooleanNumber,
Disposable,
@@ -55,7 +55,7 @@ export function getDocsTableCellDrawingOffset(
) {
const sourceTableId = getTableIdAndSliceIndex(table.tableId).tableId;
const viewport = getDocsTableRenderViewport(unitId, sourceTableId);
const hasHorizontalViewport = viewport && viewport.contentWidth > viewport.viewportWidth;
const hasHorizontalViewport = hasHorizontalTableViewport(viewport);
const scrollLeft = hasHorizontalViewport ? viewport.scrollLeft : 0;
return {
@@ -64,6 +64,11 @@ export function getDocsTableCellDrawingOffset(
};
}
function hasHorizontalTableViewport(viewport: IDocsTableRenderViewport | null | undefined): viewport is IDocsTableRenderViewport {
return viewport != null &&
(viewport.leadingInsetLeft ?? 0) + viewport.contentWidth + (viewport.trailingInsetRight ?? 0) > viewport.viewportWidth;
}
export class DocDrawingTransformUpdateController extends Disposable implements IRenderModule {
private _liquid = new Liquid();
+2
View File
@@ -20,6 +20,7 @@ export { DeleteDocDrawingsCommand } from './commands/commands/delete-doc-drawing
export { GroupDocDrawingCommand } from './commands/commands/group-doc-drawing.command';
export { InsertDocDrawingCommand } from './commands/commands/insert-doc-drawing.command';
export { InsertDocImageCommand } from './commands/commands/insert-image.command';
export { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from './commands/commands/insert-shape.command';
export { MoveDocDrawingsCommand } from './commands/commands/move-drawings.command';
export { RemoveDocDrawingCommand } from './commands/commands/remove-doc-drawing.command';
export { SetDocDrawingArrangeCommand } from './commands/commands/set-drawing-arrange.command';
@@ -30,6 +31,7 @@ export { SidebarDocDrawingOperation } from './commands/operations/open-drawing-p
export { type IUniverDocsDrawingUIConfig } from './config/config';
export { DocFloatDomController } from './controllers/doc-float-dom.controller';
export { DOCS_IMAGE_MENU_ID } from './menu/image.menu';
export { DOCS_SHAPE_BELOW_MENU_ID, DOCS_SHAPE_MENU_ID } from './menu/shape.menu';
export { UniverDocsDrawingUIPlugin } from './plugin';
export { DocDrawingPosition } from './views/doc-image-panel/DocDrawingPosition';
export { DocDrawingTextWrap } from './views/doc-image-panel/DocDrawingTextWrap';
@@ -18,8 +18,13 @@ import { DOC_CONTENT_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU
import { ContextMenuGroup, ContextMenuPosition } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { InsertDocImageCommand } from '../../commands/commands/insert-image.command';
import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../../commands/commands/insert-shape.command';
import { UploadFloatImageMenuFactory } from '../image.menu';
import { menuSchema } from '../schema';
import {
DOCS_SHAPE_BELOW_MENU_ID,
DOCS_SHAPE_MENU_ID,
} from '../shape.menu';
describe('docs drawing menu schema', () => {
it('adds image to paragraph insert menus', () => {
@@ -35,4 +40,15 @@ describe('docs drawing menu schema', () => {
expect(item.icon).toBe('AddImageIcon');
});
it('registers shape submenu options for paragraph insert menus', () => {
const paragraph = (menuSchema as any)[ContextMenuPosition.PARAGRAPH];
const rootShapeMenu = paragraph[DOCS_SHAPE_MENU_ID].shapes;
const belowShapeMenu = paragraph[DOCS_SHAPE_BELOW_MENU_ID].shapes;
expect(rootShapeMenu[InsertDocRectangleShapeCommand.id].menuItemFactory).toBeDefined();
expect(rootShapeMenu[InsertDocEllipseShapeCommand.id].menuItemFactory).toBeDefined();
expect(belowShapeMenu[`${InsertDocRectangleShapeCommand.id}.below`].menuItemFactory).toBeDefined();
expect(belowShapeMenu[`${InsertDocEllipseShapeCommand.id}.below`].menuItemFactory).toBeDefined();
});
});
@@ -17,12 +17,21 @@
import type { MenuSchemaType } from '@univerjs/ui';
import { DOC_CONTENT_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../commands/commands/insert-shape.command';
import {
DOCS_IMAGE_MENU_ID,
IMAGE_MENU_UPLOAD_FLOAT_ID,
ImageMenuFactory,
UploadFloatImageMenuFactory,
} from './image.menu';
import {
DOCS_SHAPE_BELOW_MENU_ID,
DOCS_SHAPE_MENU_ID,
InsertEllipseShapeBelowMenuFactory,
InsertEllipseShapeMenuFactory,
InsertRectangleShapeBelowMenuFactory,
InsertRectangleShapeMenuFactory,
} from './shape.menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
@@ -60,5 +69,31 @@ export const menuSchema: MenuSchemaType = {
},
},
},
[DOCS_SHAPE_MENU_ID]: {
shapes: {
order: 0,
[InsertDocRectangleShapeCommand.id]: {
order: 0,
menuItemFactory: InsertRectangleShapeMenuFactory,
},
[InsertDocEllipseShapeCommand.id]: {
order: 1,
menuItemFactory: InsertEllipseShapeMenuFactory,
},
},
},
[DOCS_SHAPE_BELOW_MENU_ID]: {
shapes: {
order: 0,
[`${InsertDocRectangleShapeCommand.id}.below`]: {
order: 0,
menuItemFactory: InsertRectangleShapeBelowMenuFactory,
},
[`${InsertDocEllipseShapeCommand.id}.below`]: {
order: 1,
menuItemFactory: InsertEllipseShapeBelowMenuFactory,
},
},
},
},
};
@@ -0,0 +1,79 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor } from '@univerjs/core';
import type { IMenuButtonItem, IMenuItem } from '@univerjs/ui';
import { UniverInstanceType } from '@univerjs/core';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../commands/commands/insert-shape.command';
export const DOCS_SHAPE_MENU_ID = 'doc.command.menu-insert-shape';
export const DOCS_SHAPE_BELOW_MENU_ID = 'doc.command.menu-insert-shape.below';
export function ShapeMenuFactory(accessor: IAccessor): IMenuItem {
return {
id: DOCS_SHAPE_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'ShapeIcon',
title: 'Insert Shape',
tooltip: 'Insert Shape',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function InsertRectangleShapeMenuFactory(accessor: IAccessor): IMenuButtonItem {
return {
id: InsertDocRectangleShapeCommand.id,
title: 'Insert Rectangle',
type: MenuItemType.BUTTON,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function InsertEllipseShapeMenuFactory(accessor: IAccessor): IMenuButtonItem {
return {
id: InsertDocEllipseShapeCommand.id,
title: 'Insert Ellipse',
type: MenuItemType.BUTTON,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function InsertRectangleShapeBelowMenuFactory(accessor: IAccessor): IMenuButtonItem {
return {
id: `${InsertDocRectangleShapeCommand.id}.below`,
commandId: InsertDocRectangleShapeCommand.id,
title: 'Insert Rectangle',
type: MenuItemType.BUTTON,
params: {
paragraphMenuPlacement: 'below',
},
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function InsertEllipseShapeBelowMenuFactory(accessor: IAccessor): IMenuButtonItem {
return {
id: `${InsertDocEllipseShapeCommand.id}.below`,
commandId: InsertDocEllipseShapeCommand.id,
title: 'Insert Ellipse',
type: MenuItemType.BUTTON,
params: {
paragraphMenuPlacement: 'below',
},
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
@@ -0,0 +1,45 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui';
import { describe, expect, it, vi } from 'vitest';
import { DocHyperLinkEdit } from '../../views/hyper-link-edit';
import { DocHyperLinkUIController } from '../ui.controller';
describe('DocHyperLinkUIController', () => {
it('creates the docs floating toolbar root before merging hyperlink menus', () => {
const registerComponent = vi.fn(() => ({ dispose: vi.fn() }));
const registerCommand = vi.fn();
const appendRootMenu = vi.fn();
const mergeMenu = vi.fn();
const registerShortcut = vi.fn();
const controller = new DocHyperLinkUIController(
{ register: registerComponent } as any,
{ registerCommand } as any,
{ appendRootMenu, mergeMenu } as any,
{ registerShortcut } as any
);
expect(registerComponent).toHaveBeenCalledWith(DocHyperLinkEdit.componentKey, DocHyperLinkEdit);
expect(appendRootMenu).toHaveBeenCalledWith({ [FLOAT_TOOLBAR_MENU_POSITION]: {} });
expect(mergeMenu).toHaveBeenCalled();
expect(appendRootMenu.mock.invocationCallOrder[0]).toBeLessThan(mergeMenu.mock.invocationCallOrder[0]);
expect(registerShortcut).toHaveBeenCalled();
controller.dispose();
});
});
@@ -16,6 +16,7 @@
import type { MenuConfig } from '@univerjs/ui';
import { Disposable, ICommandService, Inject } from '@univerjs/core';
import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui';
import { LinkIcon } from '@univerjs/icons';
import { ComponentManager, IMenuManagerService, IShortcutService } from '@univerjs/ui';
import { AddDocHyperLinkCommand } from '../commands/commands/add-link.command';
@@ -78,6 +79,7 @@ export class DocHyperLinkUIController extends Disposable {
}
private _initMenus() {
this._menuManagerService.appendRootMenu({ [FLOAT_TOOLBAR_MENU_POSITION]: {} });
this._menuManagerService.mergeMenu(menuSchema);
}
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { EMPTY_PARAGRAPH_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { ShowDocHyperLinkEditPopupOperation } from '../../commands/operations/popup.operation';
@@ -27,4 +27,12 @@ describe('docs hyperlink menu schema', () => {
expect(paragraph[ContextMenuGroup.LAYOUT][INSERT_BELLOW_MENU_ID][ShowDocHyperLinkEditPopupOperation.id].menuItemFactory).toBeDefined();
expect(paragraph[EMPTY_PARAGRAPH_MENU_ID][ContextMenuGroup.LAYOUT][ShowDocHyperLinkEditPopupOperation.id].menuItemFactory).toBeDefined();
});
it('adds hyperlink to docs text floating toolbar', () => {
const floatToolbar = (menuSchema as any)[FLOAT_TOOLBAR_MENU_POSITION];
const hyperlink = floatToolbar[ShowDocHyperLinkEditPopupOperation.id];
expect(hyperlink.order).toBe(20);
expect(hyperlink.menuItemFactory).toBeDefined();
});
});
@@ -15,7 +15,7 @@
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { EMPTY_PARAGRAPH_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { ShowDocHyperLinkEditPopupOperation } from '../commands/operations/popup.operation';
import { AddHyperLinkMenuItemFactory } from './menu';
@@ -27,6 +27,12 @@ export const menuSchema: MenuSchemaType = {
menuItemFactory: AddHyperLinkMenuItemFactory,
},
},
[FLOAT_TOOLBAR_MENU_POSITION]: {
[ShowDocHyperLinkEditPopupOperation.id]: {
order: 20,
menuItemFactory: AddHyperLinkMenuItemFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.DATA]: {
[ShowDocHyperLinkEditPopupOperation.id]: {
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui';
import { describe, expect, it, vi } from 'vitest';
import { DOCS_THREAD_COMMENT_PANEL } from '../../common/const';
import { DocThreadCommentPanel } from '../../views/doc-thread-comment-panel';
@@ -23,16 +24,19 @@ describe('DocThreadCommentUIController', () => {
it('should register commands, menus and components', () => {
const registerCommand = vi.fn(() => ({ dispose: vi.fn() }));
const mergeMenu = vi.fn();
const appendRootMenu = vi.fn();
const registerComponent = vi.fn(() => ({ dispose: vi.fn() }));
const controller = new DocThreadCommentUIController(
{ registerCommand } as any,
{ mergeMenu } as any,
{ appendRootMenu, mergeMenu } as any,
{ register: registerComponent } as any
);
expect(registerCommand).toHaveBeenCalled();
expect(appendRootMenu).toHaveBeenCalledWith({ [FLOAT_TOOLBAR_MENU_POSITION]: {} });
expect(mergeMenu).toHaveBeenCalled();
expect(appendRootMenu.mock.invocationCallOrder[0]).toBeLessThan(mergeMenu.mock.invocationCallOrder[0]);
expect(registerComponent).toHaveBeenCalledWith(DOCS_THREAD_COMMENT_PANEL, DocThreadCommentPanel);
controller.dispose();
@@ -15,6 +15,7 @@
*/
import { Disposable, ICommandService, Inject } from '@univerjs/core';
import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui';
import { CommentIcon } from '@univerjs/icons';
import { ComponentManager, IMenuManagerService } from '@univerjs/ui';
import { AddDocCommentComment } from '../commands/commands/add-doc-comment.command';
@@ -49,6 +50,7 @@ export class DocThreadCommentUIController extends Disposable {
}
private _initMenus() {
this._menuManagerService.appendRootMenu({ [FLOAT_TOOLBAR_MENU_POSITION]: {} });
this._menuManagerService.mergeMenu(menuSchema);
}
@@ -14,10 +14,13 @@
* limitations under the License.
*/
import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui';
import { DocumentEditArea } from '@univerjs/engine-render';
import { describe, expect, it, vi } from 'vitest';
import { StartAddCommentOperation } from '../../commands/operations/show-comment-panel.operation';
import { AddDocCommentMenuItemFactory, shouldDisableAddComment, ToolbarDocCommentMenuItemFactory } from '../menu';
import { menuSchema } from '../schema';
vi.mock('@univerjs/engine-render', async () => {
const actual = await vi.importActual<typeof import('@univerjs/engine-render')>('@univerjs/engine-render');
@@ -65,4 +68,12 @@ describe('docs-thread-comment-ui menu', () => {
expect(addItem.id).toBe('docs.operation.start-add-comment');
expect(toolbarItem.id).toBe('docs.operation.toggle-comment-panel');
});
it('adds comment to docs text floating toolbar', () => {
const floatToolbar = (menuSchema as any)[FLOAT_TOOLBAR_MENU_POSITION];
const comment = floatToolbar[StartAddCommentOperation.id];
expect(comment.order).toBe(21);
expect(comment.menuItemFactory).toBe(AddDocCommentMenuItemFactory);
});
});
@@ -15,6 +15,7 @@
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import {
StartAddCommentOperation,
@@ -29,6 +30,12 @@ export const menuSchema: MenuSchemaType = {
menuItemFactory: ToolbarDocCommentMenuItemFactory,
},
},
[FLOAT_TOOLBAR_MENU_POSITION]: {
[StartAddCommentOperation.id]: {
order: 21,
menuItemFactory: AddDocCommentMenuItemFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.DATA]: {
[StartAddCommentOperation.id]: {
@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import {
DOC_TABLE_BLOCK_MENU_ID,
INSERT_BELLOW_MENU_ID,
ParagraphMenuInsertBelowSubmenuItemFactory,
} from '../index';
describe('docs-ui public exports', () => {
it('re-exports table block paragraph menu ids and factories', () => {
expect(DOC_TABLE_BLOCK_MENU_ID).toBe('doc.menu.table-block');
expect(INSERT_BELLOW_MENU_ID).toBe('doc.menu.insert-bellow');
expect(ParagraphMenuInsertBelowSubmenuItemFactory).toBeTypeOf('function');
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { IMenuSchema } from '@univerjs/ui';
import type { IMenuManagerService as IMenuManagerServiceType, IMenuSchema } from '@univerjs/ui';
import { borderClassName, clsx } from '@univerjs/design';
import { IMenuManagerService, MenuManagerPosition, ToolbarItem, useDependency } from '@univerjs/ui';
import { useEffect, useState } from 'react';
@@ -48,29 +48,44 @@ const DEFAULT_AVALIABLE_MENUS: string[] = [
SetInlineFormatTextBackgroundColorCommand.id,
];
export function resolveFloatToolbarMenus(
menuManagerService: IMenuManagerServiceType,
avaliableMenus: string[]
): { menus: IMenuSchema[]; extraMenus: IMenuSchema[] } {
const floatToolbarMenus = menuManagerService.getMenuByPositionKey(FLOAT_TOOLBAR_MENU_POSITION);
const flatMenus = [
...menuManagerService.getFlatMenuByPositionKey(FLOAT_TOOLBAR_MENU_POSITION),
...menuManagerService.getFlatMenuByPositionKey(MenuManagerPosition.RIBBON),
];
const menus: IMenuSchema[] = [];
for (const key of avaliableMenus) {
const item = flatMenus.find((item) => item.key === key);
if (item) {
menus.push(item);
}
}
return {
menus,
extraMenus: floatToolbarMenus.filter((item) => item.item && !avaliableMenus.includes(item.key)),
};
}
export function FloatToolbar(props: IFloatToolbarProps) {
const { avaliableMenus = DEFAULT_AVALIABLE_MENUS } = props;
const menuManagerService = useDependency(IMenuManagerService);
const [menus, setMenus] = useState<IMenuSchema[]>([]);
const [extraMenus, setExtraMenus] = useState<IMenuSchema[]>([]);
// subscribe to menu changes
useEffect(() => {
function getRibbon(): void {
const flatMenus = [
...menuManagerService.getFlatMenuByPositionKey(FLOAT_TOOLBAR_MENU_POSITION),
...menuManagerService.getFlatMenuByPositionKey(MenuManagerPosition.RIBBON),
];
const menus: IMenuSchema[] = [];
for (const key of avaliableMenus) {
const item = flatMenus.find((item) => item.key === key);
if (item) {
menus.push(item);
}
}
const { menus, extraMenus } = resolveFloatToolbarMenus(menuManagerService, avaliableMenus);
setMenus(menus);
setExtraMenus(extraMenus);
}
getRibbon();
@@ -79,7 +94,7 @@ export function FloatToolbar(props: IFloatToolbarProps) {
return () => {
subscription.unsubscribe();
};
}, [menuManagerService]);
}, [avaliableMenus, menuManagerService]);
return (
<div
@@ -93,6 +108,19 @@ export function FloatToolbar(props: IFloatToolbarProps) {
<ToolbarItem key={groupItem.key} {...groupItem.item} />
</div>
))}
{extraMenus.length > 0 && (
<div
className="
univer-my-1 univer-w-px univer-bg-gray-200
dark:univer-bg-gray-700
"
/>
)}
{extraMenus.map((groupItem) => groupItem.item && (
<div key={groupItem.key} className="univer-flex univer-flex-nowrap univer-gap-2 univer-px-2">
<ToolbarItem key={groupItem.key} {...groupItem.item} />
</div>
))}
</div>
);
}
@@ -0,0 +1,74 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IMenuSchema } from '@univerjs/ui';
import { MenuManagerPosition } from '@univerjs/ui';
import { describe, expect, it, vi } from 'vitest';
import { SetInlineFormatBoldCommand } from '../../../commands/commands/inline-format.command';
import { FLOAT_TEXT_STYLE_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION } from '../../../menu/menu';
import { resolveFloatToolbarMenus } from '../FloatToolbar';
describe('resolveFloatToolbarMenus', () => {
it('keeps default toolbar menus ordered by whitelist and appends direct extension menus separately', () => {
const textStyleItem = createMenuItem(FLOAT_TEXT_STYLE_MENU_ID);
const boldItem = createMenuItem(SetInlineFormatBoldCommand.id);
const hyperlinkItem = createMenuItem('doc.operation.show-hyper-link-edit-popup');
const commentItem = createMenuItem('docs.operation.start-add-comment');
const menuManagerService = {
getMenuByPositionKey: vi.fn((position: string) => {
if (position === FLOAT_TOOLBAR_MENU_POSITION) {
return [textStyleItem, hyperlinkItem, commentItem];
}
return [];
}),
getFlatMenuByPositionKey: vi.fn((position: string) => {
if (position === FLOAT_TOOLBAR_MENU_POSITION) {
return [textStyleItem, hyperlinkItem, commentItem];
}
if (position === MenuManagerPosition.RIBBON) {
return [boldItem];
}
return [];
}),
};
const { menus, extraMenus } = resolveFloatToolbarMenus(menuManagerService as never, [
FLOAT_TEXT_STYLE_MENU_ID,
SetInlineFormatBoldCommand.id,
]);
expect(menus.map((item) => item.key)).toEqual([
FLOAT_TEXT_STYLE_MENU_ID,
SetInlineFormatBoldCommand.id,
]);
expect(extraMenus.map((item) => item.key)).toEqual([
'doc.operation.show-hyper-link-edit-popup',
'docs.operation.start-add-comment',
]);
});
});
function createMenuItem(key: string): IMenuSchema {
return {
key,
order: 0,
item: {
id: key,
} as never,
};
}
@@ -15,20 +15,37 @@
*/
import { NamedStyleType } from '@univerjs/core';
import { MenuItemType } from '@univerjs/ui';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createParagraphMenuHoverOpenScheduler, getParagraphMenuActiveHeadingCommandId, getParagraphMenuCommand, getParagraphMenuHiddenHeadingCommandIds, getParagraphMenuIconSizeClass, getParagraphMenuPopupDirection, getParagraphMenuTargetRange, isEmptyParagraphMenuTarget, PARAGRAPH_MENU_HOVER_OPEN_DELAY, shouldShowParagraphSettingMenu, shouldUseInsertBelowRange } from '..';
import * as paragraphMenu from '..';
import { createParagraphMenuHoverOpenScheduler, getParagraphFormattingRange, getParagraphMenuActiveHeadingCommandId, getParagraphMenuCommand, getParagraphMenuCommandTargetRange, getParagraphMenuHiddenHeadingCommandIds, getParagraphMenuHiddenItemIds, getParagraphMenuIconSizeClass, getParagraphMenuPopupDirection, getParagraphMenuTargetRange, isEmptyParagraphMenuTarget, PARAGRAPH_MENU_HOVER_OPEN_DELAY, shouldShowParagraphSettingMenu, shouldUseInsertBelowRange } from '..';
import { HorizontalLineCommand } from '../../../commands/commands/doc-horizontal-line.command';
import { SetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextColorCommand } from '../../../commands/commands/inline-format.command';
import { BulletListCommand, InsertBulletListBellowCommand, OrderListCommand } from '../../../commands/commands/list.command';
import { H1HeadingCommand, H3HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../../commands/commands/set-heading.command';
import { H1HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../../commands/commands/set-heading.command';
import { CreateDocTableCommand } from '../../../commands/commands/table/doc-table-create.command';
import {
DOC_PARAGRAPH_T_EDIT_MENU_ID,
DOC_PARAGRAPH_T_INSERT_MENU_ID,
EmptyParagraphBulletListMenuItemFactory,
EmptyParagraphH1MenuItemFactory,
EmptyParagraphHorizontalLineMenuItemFactory,
HEADING_ICON_MAP,
INSERT_BELLOW_MENU_ID,
shouldShowParagraphHeadingOption,
InsertBulletListBellowMenuItemFactory,
InsertHorizontalLineBellowMenuItemFactory,
InsertOrderListBellowMenuItemFactory,
ParagraphMenuBackgroundColorHeaderActionMenuItemFactory,
ParagraphMenuBackgroundColorSwatchMenuItemFactories,
ParagraphMenuDefaultTextColorMenuItemFactory,
ParagraphMenuIndentDecreaseMenuItemFactory,
ParagraphMenuIndentIncreaseMenuItemFactory,
ParagraphMenuNoBackgroundMenuItemFactory,
ParagraphMenuTextColorHeaderActionMenuItemFactory,
ParagraphMenuTextColorSwatchMenuItemFactories,
TableBlockCopyMenuItemFactory,
TableBlockDeleteMenuItemFactory,
TableBlockPasteMenuItemFactory,
@@ -48,6 +65,10 @@ describe('ParagraphMenu', () => {
expect(HEADING_ICON_MAP[NamedStyleType.SUBTITLE].key).toBe('SubtitleTypeIcon');
});
it('uses the enlarged context menu size variant for the docs T menu', () => {
expect((paragraphMenu as any).getParagraphMenuContextMenuSizeVariant?.()).toBe('paragraph-t');
});
it('uses fully-qualified locale keys for paragraph context menu labels', () => {
const accessor = {
get: () => ({
@@ -64,11 +85,155 @@ describe('ParagraphMenu', () => {
expect(TableBlockDeleteMenuItemFactory(accessor).title).toBe('docs-ui.rightClick.delete');
});
it('adds matching tooltips to T-menu icon actions', () => {
const accessor = {
get: () => ({
get: () => undefined,
register: () => undefined,
}),
} as never;
expect(EmptyParagraphH1MenuItemFactory(accessor).tooltip).toBe('ui.toolbar.heading.1');
expect(EmptyParagraphBulletListMenuItemFactory(accessor).tooltip).toBe('docs-ui.rightClick.bulletList');
expect(EmptyParagraphHorizontalLineMenuItemFactory(accessor).tooltip).toBe('docs-ui.toolbar.horizontalLine');
expect(InsertOrderListBellowMenuItemFactory(accessor).tooltip).toBe('docs-ui.rightClick.orderList');
expect(InsertBulletListBellowMenuItemFactory(accessor).tooltip).toBe('docs-ui.rightClick.bulletList');
expect(InsertHorizontalLineBellowMenuItemFactory(accessor).tooltip).toBe('docs-ui.toolbar.horizontalLine');
expect(ParagraphMenuIndentIncreaseMenuItemFactory(accessor).tooltip).toBe('docs-ui.paragraphMenu.increaseIndent');
expect(ParagraphMenuIndentDecreaseMenuItemFactory(accessor).tooltip).toBe('docs-ui.paragraphMenu.decreaseIndent');
expect(ParagraphMenuIndentIncreaseMenuItemFactory(accessor).title).toBe('docs-ui.paragraphMenu.increase');
expect(ParagraphMenuIndentDecreaseMenuItemFactory(accessor).title).toBe('docs-ui.paragraphMenu.decrease');
});
it('renders paragraph text color swatches with an A glyph inside an outlined color chip', () => {
const registeredIcons = new Map<string, React.ComponentType<{ className?: string }>>();
const componentManager = {
get: (key: string) => registeredIcons.get(key),
register: (key: string, component: React.ComponentType<{ className?: string }>) => {
registeredIcons.set(key, component);
},
};
const accessor = {
get: () => componentManager,
} as never;
const factory = ParagraphMenuTextColorSwatchMenuItemFactories['doc.menu.paragraph-t.text-color.0'].menuItemFactory;
const menuItem = factory(accessor);
const Icon = registeredIcons.get(menuItem.icon as string);
expect(Icon).toBeDefined();
expect(menuItem.tooltip).toBeUndefined();
const markup = renderToStaticMarkup(React.createElement(Icon!, { className: 'swatch-icon' }));
expect(markup).toContain('>A<');
expect(markup.match(/<rect/g)?.length ?? 0).toBeGreaterThan(0);
});
it('keeps paragraph background color swatches as plain color blocks', () => {
const registeredIcons = new Map<string, React.ComponentType<{ className?: string }>>();
const componentManager = {
get: (key: string) => registeredIcons.get(key),
register: (key: string, component: React.ComponentType<{ className?: string }>) => {
registeredIcons.set(key, component);
},
};
const accessor = {
get: () => componentManager,
} as never;
const factory = ParagraphMenuBackgroundColorSwatchMenuItemFactories['doc.menu.paragraph-t.background-color.0'].menuItemFactory;
const menuItem = factory(accessor);
const Icon = registeredIcons.get(menuItem.icon as string);
expect(Icon).toBeDefined();
expect(menuItem.tooltip).toBeUndefined();
const markup = renderToStaticMarkup(React.createElement(Icon!, { className: 'swatch-icon' }));
expect(markup).not.toContain('>A<');
expect(markup.match(/<rect/g)?.length ?? 0).toBeGreaterThan(0);
});
it('keeps the non-color explanatory actions in the colors submenu tooltip-free', () => {
const accessor = {
get: () => ({
get: () => undefined,
register: () => undefined,
}),
} as never;
expect(ParagraphMenuDefaultTextColorMenuItemFactory(accessor).tooltip).toBeUndefined();
expect(ParagraphMenuNoBackgroundMenuItemFactory(accessor).tooltip).toBeUndefined();
});
it('reuses the existing text color selector logic for the colors header action with an A icon', () => {
const accessor = {
get: () => ({
get: () => undefined,
register: () => undefined,
}),
} as never;
const menuItem = ParagraphMenuTextColorHeaderActionMenuItemFactory(accessor);
expect(menuItem.type).toBe(MenuItemType.BUTTON_SELECTOR);
expect(menuItem.id).toBe(SetInlineFormatTextColorCommand.id);
expect(menuItem.icon).toBe('HeaderTextColorIcon');
expect(menuItem.tooltip).toBeUndefined();
expect(Array.isArray((menuItem as any).selections)).toBe(true);
});
it('reuses the existing background color selector logic for the colors header action with the bucket icon', () => {
const accessor = {
get: () => ({
get: () => undefined,
register: () => undefined,
}),
} as never;
const menuItem = ParagraphMenuBackgroundColorHeaderActionMenuItemFactory(accessor);
expect(menuItem.type).toBe(MenuItemType.BUTTON_SELECTOR);
expect(menuItem.id).toBe(SetInlineFormatTextBackgroundColorCommand.id);
expect(menuItem.icon).toBe('PaintBucketDoubleIcon');
expect(menuItem.tooltip).toBeUndefined();
expect(Array.isArray((menuItem as any).selections)).toBe(true);
});
it('opens the popup away from the drag handle when there is not enough left space', () => {
expect(getParagraphMenuPopupDirection(170)).toBe('right');
expect(getParagraphMenuPopupDirection(260)).toBe('left');
});
it('creates a hover bridge overlapping the trigger edge for left-side paragraph popups', () => {
expect(paragraphMenu.getParagraphMenuHoverBridgeStyle?.({
left: 320,
right: 348,
top: 120,
bottom: 152,
}, 'left')).toEqual({
left: 308,
top: 112,
width: 24,
height: 48,
});
});
it('creates a hover bridge overlapping the trigger edge for right-side paragraph popups', () => {
expect(paragraphMenu.getParagraphMenuHoverBridgeStyle?.({
left: 24,
right: 52,
top: 40,
bottom: 72,
}, 'right')).toEqual({
left: 40,
top: 32,
width: 24,
height: 48,
});
});
it('delays opening the paragraph popup for hover and cancels before the delay elapses', () => {
vi.useFakeTimers();
const openMenu = vi.fn();
@@ -106,20 +271,6 @@ describe('ParagraphMenu', () => {
expect(openMenu).toHaveBeenCalledTimes(1);
});
it('shows title and subtitle heading shortcuts only when they are the current paragraph style', () => {
expect(shouldShowParagraphHeadingOption(NamedStyleType.HEADING_5, NamedStyleType.NORMAL_TEXT)).toBe(true);
expect(shouldShowParagraphHeadingOption(NamedStyleType.TITLE, NamedStyleType.NORMAL_TEXT)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.SUBTITLE, NamedStyleType.NORMAL_TEXT)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.HEADING_5, NamedStyleType.TITLE)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.TITLE, NamedStyleType.TITLE)).toBe(true);
expect(shouldShowParagraphHeadingOption(NamedStyleType.SUBTITLE, NamedStyleType.TITLE)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.HEADING_5, NamedStyleType.SUBTITLE)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.TITLE, NamedStyleType.SUBTITLE)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.SUBTITLE, NamedStyleType.SUBTITLE)).toBe(true);
});
it('maps paragraph named styles to the active heading menu item', () => {
expect(getParagraphMenuActiveHeadingCommandId(NamedStyleType.HEADING_1)).toBe(H1HeadingCommand.id);
expect(getParagraphMenuActiveHeadingCommandId(NamedStyleType.HEADING_3)).toBe(H3HeadingCommand.id);
@@ -131,14 +282,35 @@ describe('ParagraphMenu', () => {
it('hides the alternate title shortcuts for the hovered paragraph style', () => {
expect(getParagraphMenuHiddenHeadingCommandIds(NamedStyleType.TITLE)).toEqual([
H4HeadingCommand.id,
H5HeadingCommand.id,
SubtitleHeadingCommand.id,
]);
expect(getParagraphMenuHiddenHeadingCommandIds(NamedStyleType.SUBTITLE)).toEqual([
H4HeadingCommand.id,
H5HeadingCommand.id,
TitleHeadingCommand.id,
]);
expect(getParagraphMenuHiddenHeadingCommandIds(NamedStyleType.NORMAL_TEXT)).toEqual([
H5HeadingCommand.id,
TitleHeadingCommand.id,
SubtitleHeadingCommand.id,
]);
});
it('keeps all six insert-state header icons visible for empty paragraphs', () => {
expect(getParagraphMenuHiddenItemIds(
DOC_PARAGRAPH_T_INSERT_MENU_ID,
{ kind: 'paragraph' } as never,
NamedStyleType.NORMAL_TEXT
)).toEqual([]);
expect(getParagraphMenuHiddenItemIds(
DOC_PARAGRAPH_T_EDIT_MENU_ID,
{ kind: 'paragraph' } as never,
NamedStyleType.NORMAL_TEXT
)).toEqual([
H5HeadingCommand.id,
TitleHeadingCommand.id,
SubtitleHeadingCommand.id,
]);
@@ -182,6 +354,54 @@ describe('ParagraphMenu', () => {
});
});
it('builds a full formatting range for whole-paragraph color actions', () => {
expect(getParagraphFormattingRange({
kind: 'paragraph',
menuRange: {
startOffset: 3,
endOffset: 3,
collapsed: true,
},
} as never, {
paragraphStart: 3,
paragraphEnd: 8,
segmentId: 'header-1',
} as never)).toEqual({
collapsed: false,
endOffset: 8,
segmentId: 'header-1',
startOffset: 3,
});
expect(getParagraphFormattingRange({
kind: 'blockRange',
blockRange: {
startIndex: 6,
endIndex: 14,
},
menuRange: {
startOffset: 6,
endOffset: 15,
collapsed: false,
},
} as never, {
segmentId: 'body',
} as never)).toEqual({
collapsed: false,
endOffset: 15,
segmentId: 'body',
startOffset: 6,
});
});
it('uses the formatting range for whole-paragraph selection commands', () => {
const targetRange = { startOffset: 3, endOffset: 3, collapsed: true };
const formattingRange = { startOffset: 3, endOffset: 8, collapsed: false };
expect(getParagraphMenuCommandTargetRange(SetInlineFormatTextColorCommand.id, targetRange as never, formattingRange as never)).toEqual(formattingRange);
expect(getParagraphMenuCommandTargetRange(H1HeadingCommand.id, targetRange as never, formattingRange as never)).toEqual(targetRange);
});
it('preserves context menu command params for paragraph menu actions', () => {
expect(getParagraphMenuCommand({
commandId: CreateDocTableCommand.id,
@@ -14,26 +14,47 @@
* limitations under the License.
*/
import type { DocumentDataModel } from '@univerjs/core';
import type { DocumentDataModel, IDocumentBlockRange, IDocumentBody, IParagraph, ITextRun } from '@univerjs/core';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IPopup, IValueOption } from '@univerjs/ui';
import type { CSSProperties } from 'react';
import type { IMutiPageParagraphBound } from '../../services/doc-event-manager.service';
import type { IDocBlockMenuTarget } from '../../services/doc-paragraph-menu.service';
import { ICommandService, IUniverInstanceService, NamedStyleType, SliceBodyType, UniverInstanceType } from '@univerjs/core';
import { DataStreamTreeTokenType, ICommandService, IUniverInstanceService, JSONX, NamedStyleType, SliceBodyType, Tools, UniverInstanceType } from '@univerjs/core';
import { clsx } from '@univerjs/design';
import { DocContentInsertService, DocSelectionManagerService } from '@univerjs/docs';
import { DocContentInsertService, DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ComponentManager, ContextMenuPanel, ContextMenuPosition, IClipboardInterfaceService, ILayoutService, RectPopup, useDependency, useObservable } from '@univerjs/ui';
import { ComponentManager, ContextMenuPanel, IClipboardInterfaceService, ILayoutService, RectPopup, useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useMemo, useRef, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import { DocCopyCommand, DocPasteCommand } from '../../commands/commands/clipboard.command';
import { BreakLineCommand } from '../../commands/commands/break-line.command';
import { DocCopyCommand, DocCopyCurrentParagraphCommand, DocCutCurrentParagraphCommand, DocPasteCommand } from '../../commands/commands/clipboard.command';
import { MoveDocBlockCommand } from '../../commands/commands/doc-block-move.command';
import { DeleteCurrentParagraphCommand } from '../../commands/commands/doc-delete.command';
import { HorizontalLineCommand } from '../../commands/commands/doc-horizontal-line.command';
import { DocParagraphSettingCommand } from '../../commands/commands/doc-paragraph-setting.command';
import { ResetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextColorCommand } from '../../commands/commands/inline-format.command';
import { BulletListCommand, CheckListCommand, OrderListCommand } from '../../commands/commands/list.command';
import { AlignCenterCommand, AlignJustifyCommand, AlignLeftCommand, AlignRightCommand } from '../../commands/commands/paragraph-align.command';
import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../commands/commands/set-heading.command';
import { DocTableDeleteTableCommand } from '../../commands/commands/table/doc-table-delete.command';
import { DocParagraphSettingPanelOperation } from '../../commands/operations/doc-paragraph-setting-panel.operation';
import { DOC_TABLE_BLOCK_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, HEADING_ICON_MAP, INSERT_BELLOW_MENU_ID } from '../../menu/paragraph-menu';
import {
DOC_PARAGRAPH_T_DIVIDER_MENU_ID,
DOC_PARAGRAPH_T_EDIT_MENU_ID,
DOC_PARAGRAPH_T_INDENT_DECREASE_ID,
DOC_PARAGRAPH_T_INDENT_INCREASE_ID,
DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID,
DOC_PARAGRAPH_T_INSERT_MENU_ID,
DOC_PARAGRAPH_T_RESET_COLORS_ID,
DOC_TABLE_BLOCK_MENU_ID,
DOCS_CALLOUT_INSERT_COMMAND_ID,
DOCS_CODE_INSERT_COMMAND_ID,
DOCS_QUOTE_INSERT_COMMAND_ID,
HEADING_ICON_MAP,
INSERT_BELLOW_MENU_ID,
INSERT_DOC_IMAGE_COMMAND_ID,
INSERT_DOC_SHAPE_COMMAND_ID,
} from '../../menu/paragraph-menu';
import { IDocClipboardService } from '../../services/clipboard/clipboard.service';
import { DocEventManagerService } from '../../services/doc-event-manager.service';
import { DocParagraphMenuService } from '../../services/doc-paragraph-menu.service';
@@ -42,11 +63,18 @@ export function getParagraphMenuIconSizeClass(iconKey: string): string {
return iconKey === 'TextTypeIcon' ? 'univer-size-3' : 'univer-size-4';
}
export function getParagraphMenuContextMenuSizeVariant(): 'paragraph-t' {
return 'paragraph-t';
}
export function getParagraphMenuPopupDirection(anchorLeft: number, menuWidth = 212, viewportPadding = 8): 'left' | 'right' {
return anchorLeft - menuWidth < viewportPadding ? 'right' : 'left';
}
export const PARAGRAPH_MENU_HOVER_OPEN_DELAY = 800;
const PARAGRAPH_MENU_HOVER_HIDE_DELAY = 240;
const PARAGRAPH_MENU_HOVER_BRIDGE_EDGE_OVERLAP = 12;
const PARAGRAPH_MENU_HOVER_BRIDGE_VERTICAL_PADDING = 8;
export function createParagraphMenuHoverOpenScheduler(openMenu: () => void, delay = PARAGRAPH_MENU_HOVER_OPEN_DELAY) {
let openTimer: number | null = null;
@@ -105,6 +133,24 @@ export function getParagraphMenuTargetRange(paragraph?: IMutiPageParagraphBound
};
}
export function getParagraphMenuHoverBridgeStyle(
anchorRect: { left: number; right: number; top: number; bottom: number } | null | undefined,
direction: 'left' | 'right',
edgeOverlap = PARAGRAPH_MENU_HOVER_BRIDGE_EDGE_OVERLAP,
verticalPadding = PARAGRAPH_MENU_HOVER_BRIDGE_VERTICAL_PADDING
): CSSProperties | undefined {
if (!anchorRect) {
return undefined;
}
return {
left: direction === 'left' ? anchorRect.left - edgeOverlap : anchorRect.right - edgeOverlap,
top: anchorRect.top - verticalPadding,
width: edgeOverlap * 2,
height: anchorRect.bottom - anchorRect.top + verticalPadding * 2,
};
}
const HEADING_COMMAND_VALUES: Record<string, NamedStyleType> = {
[H1HeadingCommand.id]: NamedStyleType.HEADING_1,
[H2HeadingCommand.id]: NamedStyleType.HEADING_2,
@@ -133,16 +179,56 @@ export function getParagraphMenuActiveHeadingCommandId(namedStyleType?: NamedSty
export function getParagraphMenuHiddenHeadingCommandIds(namedStyleType?: NamedStyleType): string[] {
if (namedStyleType === NamedStyleType.TITLE) {
return [H5HeadingCommand.id, SubtitleHeadingCommand.id];
return [H4HeadingCommand.id, H5HeadingCommand.id, SubtitleHeadingCommand.id];
}
if (namedStyleType === NamedStyleType.SUBTITLE) {
return [H5HeadingCommand.id, TitleHeadingCommand.id];
return [H4HeadingCommand.id, H5HeadingCommand.id, TitleHeadingCommand.id];
}
return [TitleHeadingCommand.id, SubtitleHeadingCommand.id];
if (namedStyleType === NamedStyleType.HEADING_5) {
return [H4HeadingCommand.id, TitleHeadingCommand.id, SubtitleHeadingCommand.id];
}
return [H5HeadingCommand.id, TitleHeadingCommand.id, SubtitleHeadingCommand.id];
}
const LIST_ICON_TO_COMMAND_ID: Record<string, string> = {
OrderIcon: OrderListCommand.id,
UnorderIcon: BulletListCommand.id,
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_SELECTION_COMMAND_IDS = new Set([
BulletListCommand.id,
OrderListCommand.id,
CheckListCommand.id,
HorizontalLineCommand.id,
SetInlineFormatTextColorCommand.id,
SetInlineFormatTextBackgroundColorCommand.id,
DOCS_CODE_INSERT_COMMAND_ID,
DOCS_QUOTE_INSERT_COMMAND_ID,
DOCS_CALLOUT_INSERT_COMMAND_ID,
AlignLeftCommand.id,
AlignCenterCommand.id,
AlignRightCommand.id,
AlignJustifyCommand.id,
]);
const PARAGRAPH_MENU_SKIP_REPLACE_SELECTION_COMMAND_IDS = new Set([
DocCopyCurrentParagraphCommand.id,
DocCutCurrentParagraphCommand.id,
DeleteCurrentParagraphCommand.id,
INSERT_DOC_IMAGE_COMMAND_ID,
INSERT_DOC_SHAPE_COMMAND_ID,
]);
export function getParagraphMenuCommand(params: IValueOption, targetRange?: ITextRangeWithStyle | null): { commandId?: string; params?: object } {
const commandId = params.commandId ?? params.id ?? (typeof params.label === 'string' ? params.label : undefined);
if (commandId && targetRange && commandId in HEADING_COMMAND_VALUES) {
@@ -189,19 +275,289 @@ function getParagraphMenuType(target: IDocBlockMenuTarget | null | undefined, em
return DOC_TABLE_BLOCK_MENU_ID;
}
return emptyMode ? EMPTY_PARAGRAPH_MENU_ID : ContextMenuPosition.PARAGRAPH;
if (emptyMode) {
return DOC_PARAGRAPH_T_INSERT_MENU_ID;
}
if (target?.icon === 'ReduceIcon') {
return DOC_PARAGRAPH_T_DIVIDER_MENU_ID;
}
return DOC_PARAGRAPH_T_EDIT_MENU_ID;
}
export function shouldShowParagraphSettingMenu(target: IDocBlockMenuTarget | null | undefined): boolean {
return !target || target.kind === 'paragraph';
}
function getParagraphMenuActiveItemIds(target: IDocBlockMenuTarget | null | undefined, namedStyleType?: NamedStyleType): string[] {
if (target?.kind === 'blockRange') {
const blockType = target.blockRange?.blockType;
return blockType && BLOCK_TYPE_TO_COMMAND_ID[blockType]
? [BLOCK_TYPE_TO_COMMAND_ID[blockType]]
: [];
}
const activeIds = [getParagraphMenuActiveHeadingCommandId(namedStyleType)];
const listCommandId = target?.icon ? LIST_ICON_TO_COMMAND_ID[target.icon] : undefined;
if (listCommandId) {
activeIds.push(listCommandId);
}
return activeIds;
}
export function getParagraphMenuHiddenItemIds(
menuType: string,
target: IDocBlockMenuTarget | null | undefined,
namedStyleType?: NamedStyleType
): string[] {
if (menuType === DOC_PARAGRAPH_T_INSERT_MENU_ID) {
return [];
}
const hiddenIds = [...getParagraphMenuHiddenHeadingCommandIds(namedStyleType)];
const blockType = target?.kind === 'blockRange' ? target.blockRange?.blockType : undefined;
if (blockType === 'callout') {
hiddenIds.push(DOCS_CODE_INSERT_COMMAND_ID, DOCS_QUOTE_INSERT_COMMAND_ID);
} else if (blockType === 'quote' || blockType === 'code') {
hiddenIds.push(DOCS_CALLOUT_INSERT_COMMAND_ID);
}
return hiddenIds;
}
function getEndTokenOffset(body: IDocumentBody, blockRange: Pick<IDocumentBlockRange, 'endIndex'>): number {
return body.dataStream[blockRange.endIndex] === DataStreamTreeTokenType.BLOCK_END
? blockRange.endIndex
: body.dataStream[blockRange.endIndex + 1] === DataStreamTreeTokenType.BLOCK_END
? blockRange.endIndex + 1
: blockRange.endIndex;
}
function shiftPointRangesAfterDelete<T extends { startIndex: number }>(ranges: T[] | undefined, offset: number, length: number): T[] | undefined {
const endOffset = offset + length;
return ranges
?.map((range) => {
if (range.startIndex >= offset && range.startIndex < endOffset) {
return null;
}
return range.startIndex >= endOffset ? { ...range, startIndex: range.startIndex - length } : range;
})
.filter((range): range is T => range != null);
}
function shiftTextRunsAfterDelete(runs: ITextRun[] | undefined, offset: number, length: number): ITextRun[] | undefined {
const endOffset = offset + length;
return runs
?.map((run) => {
if (run.st >= offset && run.ed < endOffset) {
return null;
}
if (run.st >= endOffset) {
return {
...run,
st: run.st - length,
ed: run.ed - length,
};
}
if (run.ed >= offset) {
return {
...run,
st: run.st < offset ? run.st : offset,
ed: Math.max(offset, run.ed - length),
};
}
return run;
})
.filter((run): run is ITextRun => run != null);
}
function shiftIndexRangesAfterDelete<T extends { startIndex: number; endIndex: number }>(
ranges: T[] | undefined,
offset: number,
length: number
): T[] | undefined {
const endOffset = offset + length;
return ranges
?.map((range) => {
if (range.startIndex >= offset && range.endIndex < endOffset) {
return null;
}
if (range.startIndex >= endOffset) {
return {
...range,
startIndex: range.startIndex - length,
endIndex: range.endIndex - length,
};
}
if (range.endIndex >= offset) {
return {
...range,
startIndex: range.startIndex < offset ? range.startIndex : offset,
endIndex: Math.max(offset, range.endIndex - length),
};
}
return range;
})
.filter((range): range is T => range != null);
}
function deleteBodyText(body: IDocumentBody, startOffset: number, endOffset: number): void {
const length = endOffset - startOffset;
body.dataStream = `${body.dataStream.slice(0, startOffset)}${body.dataStream.slice(endOffset)}`;
body.paragraphs = shiftPointRangesAfterDelete(body.paragraphs, startOffset, length);
body.sectionBreaks = shiftPointRangesAfterDelete(body.sectionBreaks, startOffset, length);
body.customBlocks = shiftPointRangesAfterDelete(body.customBlocks, startOffset, length);
body.textRuns = shiftTextRunsAfterDelete(body.textRuns, startOffset, length);
body.tables = shiftIndexRangesAfterDelete(body.tables, startOffset, length);
body.customRanges = shiftIndexRangesAfterDelete(body.customRanges, startOffset, length);
body.customDecorations = shiftIndexRangesAfterDelete(body.customDecorations, startOffset, length);
body.blockRanges = shiftIndexRangesAfterDelete(body.blockRanges, startOffset, length);
}
function stripBlockParagraphStyle(style: IParagraph['paragraphStyle'], blockType: string): IParagraph['paragraphStyle'] {
const nextStyle = { ...style };
if (blockType === 'quote') {
delete nextStyle.indentStart;
delete nextStyle.spaceAbove;
delete nextStyle.spaceBelow;
return nextStyle;
}
if (blockType === 'code') {
delete nextStyle.indentStart;
delete nextStyle.indentEnd;
delete nextStyle.spaceAbove;
delete nextStyle.spaceBelow;
if (nextStyle.textStyle?.ff === 'monospace' && nextStyle.textStyle?.fs === 12) {
delete nextStyle.textStyle;
}
return nextStyle;
}
if (blockType === 'callout') {
delete nextStyle.indentStart;
delete nextStyle.indentEnd;
delete nextStyle.spaceAbove;
delete nextStyle.spaceBelow;
return nextStyle;
}
return nextStyle;
}
function unwrapBlockRangeBody(documentBody: IDocumentBody, blockRange: IDocumentBlockRange) {
const body = Tools.deepClone(documentBody);
const endTokenOffset = getEndTokenOffset(body, blockRange);
(body.paragraphs ?? [])
.filter((paragraph) => paragraph.startIndex > blockRange.startIndex && paragraph.startIndex < endTokenOffset)
.forEach((paragraph) => {
paragraph.paragraphStyle = stripBlockParagraphStyle(paragraph.paragraphStyle, blockRange.blockType);
});
deleteBodyText(body, endTokenOffset, endTokenOffset + 1);
deleteBodyText(body, blockRange.startIndex, blockRange.startIndex + 1);
body.blockRanges = body.blockRanges?.filter((range) => range.blockId !== blockRange.blockId);
return {
body,
range: {
startOffset: blockRange.startIndex,
endOffset: Math.max(blockRange.startIndex, endTokenOffset - 1),
collapsed: false,
},
};
}
function buildReplaceBodyActions(previousBody: IDocumentBody, nextBody: IDocumentBody) {
const jsonX = JSONX.getInstance();
return [
jsonX.replaceOp(['body', 'dataStream'], previousBody.dataStream, nextBody.dataStream),
jsonX.replaceOp(['body', 'paragraphs'], previousBody.paragraphs, nextBody.paragraphs),
jsonX.replaceOp(['body', 'sectionBreaks'], previousBody.sectionBreaks, nextBody.sectionBreaks),
jsonX.replaceOp(['body', 'textRuns'], previousBody.textRuns, nextBody.textRuns),
jsonX.replaceOp(['body', 'customRanges'], previousBody.customRanges, nextBody.customRanges),
jsonX.replaceOp(['body', 'customDecorations'], previousBody.customDecorations, nextBody.customDecorations),
jsonX.replaceOp(['body', 'customBlocks'], previousBody.customBlocks, nextBody.customBlocks),
jsonX.replaceOp(['body', 'tables'], previousBody.tables, nextBody.tables),
jsonX.replaceOp(['body', 'blockRanges'], previousBody.blockRanges, nextBody.blockRanges),
].reduce((acc, action) => JSONX.compose(acc, action), null as ReturnType<typeof JSONX.compose>);
}
function getTargetSelectionRange(target: IDocBlockMenuTarget | null | undefined, paragraph?: IMutiPageParagraphBound | null): ITextRangeWithStyle | null {
if (!target) {
return getParagraphMenuTargetRange(paragraph);
}
return {
...target.menuRange,
segmentId: paragraph?.segmentId,
};
}
export function getParagraphFormattingRange(target: IDocBlockMenuTarget | null | undefined, paragraph?: IMutiPageParagraphBound | null): ITextRangeWithStyle | null {
if (target?.kind === 'blockRange') {
return getBlockSelectionRange(target, paragraph);
}
if (paragraph) {
return {
startOffset: paragraph.paragraphStart,
endOffset: paragraph.paragraphEnd,
collapsed: false,
segmentId: paragraph.segmentId,
};
}
return getTargetSelectionRange(target, paragraph);
}
export function getParagraphMenuCommandTargetRange(
commandId: string | undefined,
targetRange?: ITextRangeWithStyle | null,
formattingRange?: ITextRangeWithStyle | null
): ITextRangeWithStyle | null | undefined {
if (commandId && PARAGRAPH_MENU_SELECTION_COMMAND_IDS.has(commandId)) {
return formattingRange ?? targetRange;
}
return targetRange ?? formattingRange;
}
function getBlockSelectionRange(target: IDocBlockMenuTarget | null | undefined, paragraph?: IMutiPageParagraphBound | null): ITextRangeWithStyle | null {
if (target?.kind !== 'blockRange') {
return getTargetSelectionRange(target, paragraph);
}
const blockRange = target.blockRange;
if (!blockRange) {
return getTargetSelectionRange(target, paragraph);
}
return {
startOffset: blockRange.startIndex,
endOffset: blockRange.endIndex + 1,
collapsed: false,
segmentId: paragraph?.segmentId,
};
}
export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
const [visible, setVisible] = useState(false);
const [emptyMode, setEmptyMode] = useState(false);
const [anchorRect, setAnchorRect] = useState<{ left: number; right: number; top: number; bottom: number } | null>(null);
const [dropRect, setDropRect] = useState<{ left: number; right: number; top: number; bottom: number } | null>(null);
const [menuDirection, setMenuDirection] = useState<'left' | 'right'>('left');
const contentRef = useRef<HTMLDivElement>(null);
const targetRangeRef = useRef<ITextRangeWithStyle | null>(null);
const dragTargetOffsetRef = useRef<number | null>(null);
const dragRangeRef = useRef<{ startOffset: number; endOffset: number } | null>(null);
@@ -234,18 +590,23 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
const paragraphObj = useMemo(() => doc?.getBody()?.paragraphs?.find((p) => p.startIndex === startIndex), [doc, startIndex]);
const isEmptyParagraph = currentActiveTarget?.emptyMode ?? isEmptyParagraphMenuTarget(dataStream, activeParagraphBound);
const namedStyleType = paragraphObj?.paragraphStyle?.namedStyleType;
const activeHeadingCommandId = getParagraphMenuActiveHeadingCommandId(namedStyleType);
const hiddenHeadingCommandIds = useMemo(() => getParagraphMenuHiddenHeadingCommandIds(namedStyleType), [namedStyleType]);
const hiddenItemIds = useMemo(() => {
if (!shouldShowParagraphSettingMenu(currentActiveTarget)) {
return [...hiddenHeadingCommandIds, DocParagraphSettingPanelOperation.id];
}
return hiddenHeadingCommandIds;
}, [currentActiveTarget, hiddenHeadingCommandIds]);
const icon = HEADING_ICON_MAP[namedStyleType ?? NamedStyleType.NORMAL_TEXT];
const targetIconKey = currentActiveTarget?.icon ?? icon.key;
const TargetIcon = componentManager.get(targetIconKey) ?? icon.component;
const paragraphMenuType = useMemo(
() => getParagraphMenuType(currentActiveTarget, isEmptyParagraph),
[currentActiveTarget, isEmptyParagraph]
);
const paragraphMenuActiveItemIds = useMemo(
() => getParagraphMenuActiveItemIds(currentActiveTarget, namedStyleType),
[currentActiveTarget, namedStyleType]
);
const paragraphMenuHiddenItemIds = useMemo(
() => currentActiveTarget?.kind === 'table'
? []
: getParagraphMenuHiddenItemIds(paragraphMenuType, currentActiveTarget, namedStyleType),
[currentActiveTarget, namedStyleType, paragraphMenuType]
);
const anchorRect$ = useMemo(() => new BehaviorSubject({
left: 0,
right: 0,
@@ -257,12 +618,14 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
const boundingRect = anchorRef.current?.getBoundingClientRect();
const left = (boundingRect?.left ?? 0) - 4;
setMenuDirection(getParagraphMenuPopupDirection(left));
anchorRect$.next({
const nextAnchorRect = {
left,
right: boundingRect?.right ?? 0,
top: boundingRect?.top ?? 0,
bottom: boundingRect?.bottom ?? 0,
});
};
setAnchorRect(nextAnchorRect);
anchorRect$.next(nextAnchorRect);
};
const handleHideMenu = () => {
@@ -283,7 +646,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
if (!isMouseOver.current && !isDraggingRef.current) {
handleHideMenu();
}
}, 180);
}, PARAGRAPH_MENU_HOVER_HIDE_DELAY);
};
const handleOpenMenu = () => {
@@ -297,7 +660,6 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
: getParagraphMenuTargetRange(activeParagraphBound);
targetRangeRef.current = targetRange;
updateAnchorRect();
setEmptyMode(isEmptyParagraph);
setVisible(true);
};
openMenuRef.current = handleOpenMenu;
@@ -311,6 +673,199 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
hoverOpenSchedulerRef.current.cancel();
};
const replaceSelection = (range: ITextRangeWithStyle | null | undefined) => {
if (!range) {
return;
}
docSelectionManagerService.replaceTextRanges([range], false);
};
const executeResolvedCommand = (commandId: string, params?: Record<string, unknown>, targetRange?: ITextRangeWithStyle | null) => {
const resolved = getParagraphMenuCommand({
commandId,
id: commandId,
params,
}, targetRange);
if (!resolved.commandId) {
return false;
}
return commandService.executeCommand(resolved.commandId, resolved.params);
};
const unwrapActiveBlockRange = async () => {
const latestTarget = docParagraphMenuService?.activeTarget ?? activeTarget;
if (!latestTarget || latestTarget.kind !== 'blockRange' || !latestTarget.blockRange || !doc?.getBody()) {
return null;
}
const previousBody = doc.getBody()!;
const { body, range } = unwrapBlockRangeBody(previousBody, latestTarget.blockRange);
const actions = buildReplaceBodyActions(previousBody, body);
const segmentId = activeParagraphBound?.segmentId ?? '';
const success = await commandService.executeCommand(RichTextEditingMutation.id, {
unitId: popup.unitId,
actions,
textRanges: [{
...range,
segmentId,
}],
});
if (!success) {
return null;
}
const nextRange = {
...range,
segmentId,
};
replaceSelection(nextRange);
targetRangeRef.current = nextRange;
return nextRange;
};
const executeParagraphMenuOption = async (option: IValueOption) => {
const latestTarget = docParagraphMenuService?.activeTarget ?? activeTarget;
const targetRange = getTargetSelectionRange(latestTarget, activeParagraphBound ?? undefined);
const blockRange = getBlockSelectionRange(latestTarget, activeParagraphBound ?? undefined);
const formattingRange = getParagraphFormattingRange(latestTarget, activeParagraphBound ?? undefined);
const commandId = option.commandId ?? option.id ?? (typeof option.label === 'string' ? option.label : undefined);
const commandParams = typeof option.params === 'function' ? option.params() : option.params;
if (!commandId) {
return;
}
if (commandId === DOC_PARAGRAPH_T_RESET_COLORS_ID) {
if (formattingRange) {
replaceSelection(formattingRange);
}
await commandService.executeCommand(SetInlineFormatTextColorCommand.id, { value: '#000000' });
await commandService.executeCommand(ResetInlineFormatTextBackgroundColorCommand.id);
layoutService.focus();
handleHideMenu();
return;
}
if (commandId === DOC_PARAGRAPH_T_INDENT_INCREASE_ID || commandId === DOC_PARAGRAPH_T_INDENT_DECREASE_ID) {
if (formattingRange) {
replaceSelection(formattingRange);
}
const currentIndent = paragraphObj?.paragraphStyle?.indentFirstLine?.v ?? 0;
const delta = commandId === DOC_PARAGRAPH_T_INDENT_INCREASE_ID ? 20 : -20;
await commandService.executeCommand(DocParagraphSettingCommand.id, {
paragraph: {
indentFirstLine: {
v: Math.max(0, currentIndent + delta),
},
},
});
layoutService.focus();
handleHideMenu();
return;
}
if (commandId === DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID) {
if (!latestTarget?.moveRange) {
return;
}
const insertRange = {
startOffset: latestTarget.moveRange.endOffset,
endOffset: latestTarget.moveRange.endOffset,
collapsed: true,
segmentId: activeParagraphBound?.segmentId,
};
replaceSelection(insertRange);
await commandService.executeCommand(BreakLineCommand.id, {
textRange: insertRange,
});
const wrappedCommandId = typeof commandParams?.commandId === 'string' ? commandParams.commandId : undefined;
if (wrappedCommandId) {
await commandService.executeCommand(wrappedCommandId);
}
layoutService.focus();
handleHideMenu();
return;
}
if (latestTarget?.kind === 'blockRange') {
const currentBlockCommandId = BLOCK_TYPE_TO_COMMAND_ID[latestTarget.blockRange?.blockType ?? ''];
if (commandId === currentBlockCommandId) {
await unwrapActiveBlockRange();
layoutService.focus();
handleHideMenu();
return;
}
if (
commandId === NormalTextHeadingCommand.id ||
commandId in HEADING_COMMAND_VALUES ||
commandId === BulletListCommand.id ||
commandId === OrderListCommand.id ||
commandId === CheckListCommand.id
) {
const nextRange = await unwrapActiveBlockRange();
if (commandId !== NormalTextHeadingCommand.id) {
await executeResolvedCommand(commandId, commandParams as Record<string, unknown> | undefined, nextRange);
}
layoutService.focus();
handleHideMenu();
return;
}
if (
commandId === DOCS_CODE_INSERT_COMMAND_ID ||
commandId === DOCS_QUOTE_INSERT_COMMAND_ID ||
commandId === DOCS_CALLOUT_INSERT_COMMAND_ID
) {
if (blockRange) {
replaceSelection(blockRange);
}
await executeResolvedCommand(commandId, commandParams as Record<string, unknown> | undefined, blockRange);
layoutService.focus();
handleHideMenu();
return;
}
}
if (commandId === getParagraphMenuActiveHeadingCommandId(namedStyleType) && commandId !== NormalTextHeadingCommand.id) {
if (targetRange) {
replaceSelection(targetRange);
}
await executeResolvedCommand(NormalTextHeadingCommand.id, undefined, targetRange);
layoutService.focus();
handleHideMenu();
return;
}
if (commandId && shouldUseInsertBelowRange(commandId, option) && latestTarget?.moveRange) {
docContentInsertService.setInsertRange({
unitId: popup.unitId,
startOffset: latestTarget.moveRange.endOffset,
endOffset: latestTarget.moveRange.endOffset,
segmentId: targetRange?.segmentId ?? activeParagraphBound?.segmentId ?? '',
});
} else if (PARAGRAPH_MENU_SELECTION_COMMAND_IDS.has(commandId) && !PARAGRAPH_MENU_SKIP_REPLACE_SELECTION_COMMAND_IDS.has(commandId)) {
replaceSelection(getParagraphMenuCommandTargetRange(commandId, targetRange, formattingRange));
}
await executeResolvedCommand(
commandId,
commandParams as Record<string, unknown> | undefined,
getParagraphMenuCommandTargetRange(commandId, targetRange, formattingRange)
);
layoutService.focus();
handleHideMenu();
};
const hoverBridgeStyle = visible ? getParagraphMenuHoverBridgeStyle(anchorRect, menuDirection) : undefined;
useEffect(() => () => {
if (hideTimerRef.current != null) {
window.clearTimeout(hideTimerRef.current);
@@ -454,6 +1009,23 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
</button>
)}
</div>
{visible && hoverBridgeStyle && (
<div
aria-hidden="true"
className="univer-fixed univer-z-[1019] univer-bg-transparent"
style={hoverBridgeStyle}
// Keep hover continuity when the pointer crosses the tiny dead zone between trigger and popup.
onMouseEnter={(e) => {
popup.onPointerEnter?.(e);
isMouseOver.current = true;
clearHideTimer();
}}
onMouseLeave={() => {
isMouseOver.current = false;
scheduleHideMenu();
}}
/>
)}
{visible && (
<RectPopup
portal
@@ -461,7 +1033,6 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
direction={menuDirection}
>
<section
ref={contentRef}
onMouseEnter={(e) => {
popup.onPointerEnter?.(e);
isMouseOver.current = true;
@@ -474,23 +1045,15 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
>
<ContextMenuPanel
className="univer-w-[212px]"
menuType={getParagraphMenuType(currentActiveTarget, emptyMode)}
activeItemIds={[activeHeadingCommandId]}
hiddenItemIds={hiddenItemIds}
menuType={paragraphMenuType}
sizeVariant={getParagraphMenuContextMenuSizeVariant()}
activeItemIds={currentActiveTarget?.kind === 'table' ? undefined : paragraphMenuActiveItemIds}
hiddenItemIds={currentActiveTarget?.kind === 'table' ? undefined : paragraphMenuHiddenItemIds}
onOptionSelect={async (params) => {
const targetRange = targetRangeRef.current ?? getParagraphMenuTargetRange(activeParagraphBound);
const { commandId, params: commandParams } = getParagraphMenuCommand(params, targetRange);
const latestTarget = docParagraphMenuService?.activeTarget ?? activeTarget;
if (commandId && shouldUseInsertBelowRange(commandId, params) && latestTarget?.moveRange) {
docContentInsertService.setInsertRange({
unitId: popup.unitId,
startOffset: latestTarget.moveRange.endOffset,
endOffset: latestTarget.moveRange.endOffset,
segmentId: targetRange?.segmentId ?? '',
});
}
if (latestTarget?.kind === 'table' && commandId && targetRange) {
const tableRange = {
...targetRange,
@@ -525,21 +1088,17 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
} else if (params.id === INSERT_BELLOW_MENU_ID || commandId !== INSERT_BELLOW_MENU_ID) {
docSelectionManagerService.replaceTextRanges([afterTableRange], false);
}
if (commandService && commandId) {
commandService.executeCommand(commandId, commandParams);
}
layoutService.focus();
handleHideMenu();
return;
}
if (commandService && commandId) {
const blockRangeParams = latestTarget?.kind === 'blockRange' && latestTarget.blockRange && commandParams && typeof commandParams === 'object'
? {
...commandParams,
unitId: popup.unitId,
blockId: latestTarget.blockRange.blockId,
}
: commandParams;
commandService.executeCommand(commandId, blockRangeParams);
}
layoutService.focus();
handleHideMenu();
await executeParagraphMenuOption(params);
}}
/>
</section>
@@ -567,6 +1126,11 @@ export function shouldUseInsertBelowRange(commandId: string, params: IValueOptio
return true;
}
const rawParams = typeof params.params === 'function' ? params.params() : params.params;
if (rawParams && typeof rawParams === 'object' && 'paragraphMenuPlacement' in rawParams && rawParams.paragraphMenuPlacement === 'below') {
return true;
}
const normalized = commandId.toLowerCase();
if (normalized.includes('insert') && (normalized.includes('below') || normalized.includes('bellow'))) {
@@ -577,7 +1141,13 @@ export function shouldUseInsertBelowRange(commandId: string, params: IValueOptio
return true;
}
return normalized === 'doc.command.create-table' || normalized === 'doc.operation.create-table';
if (normalized.includes('table') && normalized.includes('create')) {
return true;
}
return normalized === 'doc.command.create-table'
|| normalized === 'doc.operation.create-table'
|| normalized === DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID;
}
function DragHandleDotsIcon() {
@@ -19,6 +19,7 @@ import { DocumentFlavor } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DOCS_VIEW_KEY } from '../../basics/docs-view-key';
import { DocRenderController } from '../render-controllers/doc.render-controller';
vi.mock('@univerjs/engine-render', async (importOriginal) => {
@@ -76,14 +77,19 @@ vi.mock('@univerjs/engine-render', async (importOriginal) => {
};
});
function createControllerFixture() {
function createControllerFixture(options?: {
documentFlavor?: DocumentFlavor;
pages?: Array<Record<string, unknown>>;
}) {
const commandCallbacks: Array<(command: ICommandInfo) => void> = [];
const skeleton = {
calculate: vi.fn(),
getSkeletonData: vi.fn(() => ({
pages: [{
pages: options?.pages ?? [{
pageWidth: 640,
pageHeight: 900,
skeDrawings: new Map(),
skeTables: new Map(),
}],
})),
getViewModel: vi.fn(() => ({
@@ -102,7 +108,7 @@ function createControllerFixture() {
getUnitId: vi.fn(() => 'doc-unit'),
getSnapshot: vi.fn(() => ({
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
documentFlavor: options?.documentFlavor ?? DocumentFlavor.TRADITIONAL,
},
})),
},
@@ -121,6 +127,7 @@ function createControllerFixture() {
getCanvasEle: vi.fn(() => ({ style: {} })),
})),
},
mainComponent: undefined as { width: number } | undefined,
components: new Map(),
activated$: new Subject<boolean>(),
};
@@ -163,6 +170,8 @@ function createControllerFixture() {
return {
commandCallbacks,
context,
skeletonManager,
pageLayoutService,
selectionManager,
};
@@ -183,4 +192,32 @@ describe('doc render controller', () => {
expect(pageLayoutService.calculatePagePosition).toHaveBeenCalledTimes(1);
expect(selectionManager.refreshSelection).toHaveBeenCalledTimes(1);
});
it('keeps modern doc width anchored to page width when a table grows wider than the text column', () => {
const { context, skeletonManager } = createControllerFixture({
documentFlavor: DocumentFlavor.MODERN,
pages: [{
pageWidth: 960,
pageHeight: Number.POSITIVE_INFINITY,
width: 960,
height: 640,
marginLeft: 66.66666666666667,
marginRight: 66.66666666666667,
marginTop: 72,
marginBottom: 72,
skeDrawings: new Map(),
skeTables: new Map([['table-1', {
left: 0,
top: 180,
width: 1254,
height: 240,
}]]),
}],
});
skeletonManager.currentSkeletonBefore$.next(skeletonManager.getSkeleton());
expect(context.mainComponent?.width).toBe(960);
expect((context.components.get(DOCS_VIEW_KEY.BACKGROUND) as { width: number }).width).toBe(960);
});
});
@@ -16,7 +16,7 @@
import { DocumentFlavor } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { shouldHandleDocWheelZoom } from '../zoom.render-controller';
import { getRuntimeDocZoomRatio, shouldHandleDocWheelZoom } from '../zoom.render-controller';
describe('DocZoomRenderController', () => {
it('handles wheel zoom intent for focused modern docs', () => {
@@ -28,4 +28,14 @@ describe('DocZoomRenderController', () => {
expect(shouldHandleDocWheelZoom({ ctrlKey: false, metaKey: false }, true, DocumentFlavor.TRADITIONAL)).toBe(false);
expect(shouldHandleDocWheelZoom({ ctrlKey: true, metaKey: false }, false, DocumentFlavor.TRADITIONAL)).toBe(false);
});
it('uses a larger runtime zoom default for modern docs without persisting it', () => {
expect(getRuntimeDocZoomRatio(undefined, DocumentFlavor.MODERN)).toBe(1.2);
expect(getRuntimeDocZoomRatio(undefined, DocumentFlavor.TRADITIONAL)).toBe(1);
});
it('keeps explicit document zoom settings ahead of runtime defaults', () => {
expect(getRuntimeDocZoomRatio(0.85, DocumentFlavor.MODERN)).toBe(0.85);
expect(getRuntimeDocZoomRatio(1.5, DocumentFlavor.TRADITIONAL)).toBe(1.5);
});
});
@@ -331,7 +331,9 @@ function getPageSizeInModernMode(page: IDocumentSkeletonPage) {
}
for (const table of skeTables.values()) {
pageWidth = Math.max(pageWidth, table.left + table.width + marginLeft + marginRight);
// Keep the modern document page anchored to its configured width. Tables
// may render beyond the text column, but they should not widen the whole
// document and recenter the page during column resize.
pageHeight = Math.max(pageHeight, table.top + table.height + marginTop + marginBottom);
}
@@ -17,8 +17,6 @@
import type { DocumentDataModel, ICommandInfo, Workbook } from '@univerjs/core';
import type { IRenderContext, IRenderModule, IWheelEvent } from '@univerjs/engine-render';
import type { IDocPageSetupCommandParams } from '../../commands/commands/doc-page-setup.command';
import type { ISetDocZoomRatioOperationParams } from '../../commands/operations/set-doc-zoom-ratio.operation';
import {
Disposable,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
@@ -49,6 +47,14 @@ export function shouldHandleDocWheelZoom(
return focusingDoc && (event.ctrlKey || event.metaKey);
}
export function getDefaultDocZoomRatio(documentFlavor?: DocumentFlavor): number {
return documentFlavor === DocumentFlavor.MODERN ? 1.2 : 1;
}
export function getRuntimeDocZoomRatio(savedZoomRatio?: number, documentFlavor?: DocumentFlavor): number {
return savedZoomRatio ?? getDefaultDocZoomRatio(documentFlavor);
}
export class DocZoomRenderController extends Disposable implements IRenderModule {
private _isSheetEditor = false;
private _initTimer: number;
@@ -73,7 +79,15 @@ export class DocZoomRenderController extends Disposable implements IRenderModule
const currentSheet = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const sheetRenderer = currentSheet && this._renderManagerService.getRenderById(currentSheet.getUnitId());
// TODO: do not use setTimeout.
this._initTimer = window.setTimeout(() => this.updateViewZoom(sheetRenderer && this._isSheetEditor ? sheetRenderer.scene.scaleX : 1, true), 20);
this._initTimer = window.setTimeout(() => {
const documentModel = this._univerInstanceService.getCurrentUniverDocInstance();
const zoomRatio = sheetRenderer && this._isSheetEditor
? sheetRenderer.scene.scaleX
: documentModel
? this._getRuntimeZoomRatio(documentModel)
: 1;
this.updateViewZoom(zoomRatio, true);
}, 20);
if (!isInternalEditorID(this._context.unitId)) {
this._initZoomEventListener();
@@ -97,7 +111,9 @@ export class DocZoomRenderController extends Disposable implements IRenderModule
this._updateTimer = window.setTimeout(() => {
const currentSheet = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const sheetRenderer = currentSheet && this._renderManagerService.getRenderById(currentSheet.getUnitId());
const zoomRatio = !this._isSheetEditor ? documentModel.zoomRatio : sheetRenderer?.scene.scaleX || 1;
const zoomRatio = !this._isSheetEditor
? this._getRuntimeZoomRatio(documentModel)
: sheetRenderer?.scene.scaleX || 1;
this.updateViewZoom(zoomRatio, false);
});
@@ -105,29 +121,35 @@ export class DocZoomRenderController extends Disposable implements IRenderModule
}
private _initCommandExecutedListener() {
const updateCommandList = [SetDocZoomRatioOperation.id];
const updateCommandList = [SetDocZoomRatioOperation.id, SwitchDocModeCommand.id, DocPageSetupCommand.id];
this.disposeWithMe(this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (updateCommandList.includes(command.id) && (command.params as ISetDocZoomRatioOperationParams).unitId === this._context.unitId) {
const documentModel = this._context.unit;
if (!updateCommandList.includes(command.id)) {
return;
}
const unitId = (command.params as { unitId?: string; documentId?: string } | undefined)?.unitId
?? (command.params as { unitId?: string; documentId?: string } | undefined)?.documentId
?? this._context.unitId;
if (unitId !== this._context.unitId) {
return;
}
const documentModel = this._context.unit;
if (command.id === SetDocZoomRatioOperation.id) {
const zoomRatio = documentModel.zoomRatio || 1;
this.updateViewZoom(zoomRatio);
return;
}
if (documentModel.getSettings()?.zoomRatio != null) {
return;
}
this.updateViewZoom(this._getRuntimeZoomRatio(documentModel));
}));
this.disposeWithMe(
this._commandService.beforeCommandExecuted((command: ICommandInfo) => {
const shouldResetZoom = command.id === SwitchDocModeCommand.id ||
(command.id === DocPageSetupCommand.id && (command.params as IDocPageSetupCommandParams | undefined)?.documentFlavor === DocumentFlavor.MODERN);
if (shouldResetZoom) {
this._commandService.executeCommand(SetDocZoomRatioCommand.id, {
zoomRatio: 1,
unitId: this._context.unitId,
});
}
})
);
}
updateViewZoom(zoomRatio: number, needRefreshSelection = true) {
@@ -164,7 +186,7 @@ export class DocZoomRenderController extends Disposable implements IRenderModule
return;
}
const currentRatio = documentModel.zoomRatio || 1;
const currentRatio = this._getRuntimeZoomRatio(documentModel);
const nextRatio = getNextWheelZoomRatio(currentRatio, e);
this._commandService.executeCommand(SetDocZoomRatioCommand.id, {
@@ -176,4 +198,11 @@ export class DocZoomRenderController extends Disposable implements IRenderModule
})
);
}
private _getRuntimeZoomRatio(documentModel: DocumentDataModel): number {
return getRuntimeDocZoomRatio(
documentModel.getSettings()?.zoomRatio,
documentModel.getSnapshot().documentStyle?.documentFlavor
);
}
}
+15 -1
View File
@@ -111,7 +111,21 @@ export { DocUIController } from './controllers/doc-ui.controller';
export { DocBackScrollRenderController } from './controllers/render-controllers/back-scroll.render-controller';
export { DocRenderController } from './controllers/render-controllers/doc.render-controller';
export { FLOAT_TEXT_STYLE_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION, hideMenuWhenSelectionInBlockRange, isTextRangeInAnyBlockRange } from './menu/menu';
export { DOC_CONTENT_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, getDocBlockRangeMenuId, INSERT_BELLOW_MENU_ID } from './menu/paragraph-menu';
export {
DOC_CONTENT_INSERT_MENU_ID,
DOC_PARAGRAPH_T_ALIGN_MENU_ID,
DOC_PARAGRAPH_T_COLORS_MENU_ID,
DOC_PARAGRAPH_T_DIVIDER_MENU_ID,
DOC_PARAGRAPH_T_EDIT_MENU_ID,
DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID,
DOC_PARAGRAPH_T_INSERT_MENU_ID,
DOC_TABLE_BLOCK_MENU_ID,
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';
export { UniverDocsUIPlugin } from './plugin';
export * from './services';
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'قائمة مهام',
insertBellow: 'إدراج أدناه',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'إعداد المستند',
mode: 'الوضع',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Llista de tasques',
insertBellow: 'Insereix a sota',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Configuració de document',
mode: 'Mode',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Aufgabenliste',
insertBellow: 'Unten einfügen',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Dokumenteneinstellung',
mode: 'Modus',
+12
View File
@@ -108,6 +108,18 @@ const locale = {
checkList: 'Task list',
insertBellow: 'Insert below',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Document Setting',
mode: 'Mode',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Lista de tareas',
insertBellow: 'Insertar debajo',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Configuración de documento',
mode: 'Modo',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'لیست وظیفه',
insertBellow: 'درج در پایین',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'تنظیمات سند',
mode: 'حالت',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Liste de tâches',
insertBellow: 'Insérer dans le bas',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Paramètres du document',
mode: 'Mode',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Daftar tugas',
insertBellow: 'Sisipkan di bawah',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Pengaturan Dokumen',
mode: 'Mode',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Elenco attività',
insertBellow: 'Inserisci sotto',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Impostazione Documento',
mode: 'Modalità',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'チェックリスト',
insertBellow: '下に挿入',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': '文書設定',
mode: 'モード',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: '할 일 목록',
insertBellow: '아래에 삽입',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': '문서 설정',
mode: '모드',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Lista zadań',
insertBellow: 'Wstaw poniżej',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Ustawienia dokumentu',
mode: 'Tryb',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Lista de tarefas',
insertBellow: 'Inserir abaixo',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Configuração do documento',
mode: 'Modo',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Список задач',
insertBellow: 'Вставить ниже',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Настройки документа',
mode: 'Режим',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Zoznam úloh',
insertBellow: 'Vložiť pod',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Nastavenia dokumentu',
mode: 'Režim',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: 'Danh sách công việc',
insertBellow: 'Chèn dưới',
},
paragraphMenu: {
alignAndIndent: 'Align and indent',
align: 'Align',
indent: 'Indent',
color: 'Colors',
increase: 'Increase',
decrease: 'Decrease',
increaseIndent: 'Increase indent',
decreaseIndent: 'Decrease indent',
defaultTextColor: 'Default text color',
noBackground: 'No background',
},
'page-settings': {
'document-setting': 'Cài đặt tài liệu',
mode: 'Chế độ',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: '任务列表',
insertBellow: '在下方插入',
},
paragraphMenu: {
alignAndIndent: '对齐和缩进',
align: '对齐',
indent: '缩进',
color: '颜色',
increase: '增加',
decrease: '减少',
increaseIndent: '增加缩进',
decreaseIndent: '减少缩进',
defaultTextColor: '默认文字颜色',
noBackground: '无背景色',
},
'page-settings': {
'document-setting': '文档设置',
mode: '模式',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: '任務列表',
insertBellow: '在下方插入',
},
paragraphMenu: {
alignAndIndent: '對齊與縮進',
align: '對齊',
indent: '縮進',
color: '顏色',
increase: '增加',
decrease: '減少',
increaseIndent: '增加縮進',
decreaseIndent: '減少縮進',
defaultTextColor: '預設文字顏色',
noBackground: '無背景色',
},
'page-settings': {
'document-setting': '文檔設置',
mode: '模式',
+12
View File
@@ -110,6 +110,18 @@ const locale: typeof enUS = {
checkList: '任務列表',
insertBellow: '在下方插入',
},
paragraphMenu: {
alignAndIndent: '對齊與縮排',
align: '對齊',
indent: '縮排',
color: '顏色',
increase: '增加',
decrease: '減少',
increaseIndent: '增加縮排',
decreaseIndent: '減少縮排',
defaultTextColor: '預設文字顏色',
noBackground: '無背景色',
},
'page-settings': {
'document-setting': '文檔設置',
mode: '模式',
@@ -17,17 +17,47 @@
import { NamedStyleType, PresetListType } from '@univerjs/core';
import { ContextMenuGroup, ContextMenuPosition, MenuItemType, RibbonInsertGroup, RibbonStartGroup } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { DocCopyCurrentParagraphCommand, DocCutCurrentParagraphCommand } from '../../commands/commands/clipboard.command';
import { DeleteCurrentParagraphCommand } from '../../commands/commands/doc-delete.command';
import { HorizontalLineCommand, InsertHorizontalLineBellowCommand } from '../../commands/commands/doc-horizontal-line.command';
import { ResetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextColorCommand } from '../../commands/commands/inline-format.command';
import { BulletListCommand, CheckListCommand, InsertBulletListBellowCommand, InsertCheckListBellowCommand, InsertOrderListBellowCommand, OrderListCommand } from '../../commands/commands/list.command';
import { AlignCenterCommand, AlignJustifyCommand, AlignLeftCommand, AlignOperationCommand, AlignRightCommand } from '../../commands/commands/paragraph-align.command';
import { H1HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../commands/commands/set-heading.command';
import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../commands/commands/set-heading.command';
import { SwitchDocModeCommand } from '../../commands/commands/switch-doc-mode.command';
import { CreateDocTableCommand } from '../../commands/commands/table/doc-table-create.command';
import { DocTableDeleteTableCommand } from '../../commands/commands/table/doc-table-delete.command';
import { DocCreateTableOperation } from '../../commands/operations/doc-create-table.operation';
import { DocParagraphSettingPanelOperation } from '../../commands/operations/doc-paragraph-setting-panel.operation';
import { AlignMenuItemFactory, DocSwitchModeMenuItemFactory, FLOAT_TEXT_STYLE_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION, FloatTextStyleMenuItemFactory, InsertDefaultTableMenuFactory, InsertTableMenuFactory } from '../menu';
import { DOC_CONTENT_INSERT_MENU_ID, DOC_TABLE_BLOCK_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '../paragraph-menu';
import {
DOC_CONTENT_INSERT_MENU_ID,
DOC_PARAGRAPH_T_ALIGN_MENU_ID,
DOC_PARAGRAPH_T_COLORS_MENU_ID,
DOC_PARAGRAPH_T_DIVIDER_MENU_ID,
DOC_PARAGRAPH_T_EDIT_MENU_ID,
DOC_PARAGRAPH_T_INDENT_DECREASE_ID,
DOC_PARAGRAPH_T_INDENT_INCREASE_ID,
DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID,
DOC_PARAGRAPH_T_INSERT_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,
ParagraphMenuBackgroundColorHeaderActionMenuItemFactory,
ParagraphMenuDefaultTextColorMenuItemFactory,
ParagraphMenuInsertBelowShapeMenuItemFactory,
ParagraphMenuInsertShapeMenuItemFactory,
ParagraphMenuTextColorHeaderActionMenuItemFactory,
} from '../paragraph-menu';
import { menuSchema } from '../schema';
describe('docs ui ribbon schema', () => {
@@ -183,4 +213,143 @@ describe('docs ui ribbon schema', () => {
expect(item.icon).toBe('GridIcon');
expect(item.title).toBe('docs-ui.toolbar.table.insert');
});
it('builds the insert-state T menu from official menu schema groups', () => {
const paragraph = (menuSchema as any)[ContextMenuPosition.PARAGRAPH];
const insertMenu = paragraph[DOC_PARAGRAPH_T_INSERT_MENU_ID];
const quickTop = insertMenu.quickTop;
const quickBottom = insertMenu.quickBottom;
const insert = insertMenu.insert;
expect(quickTop.quickLayout).toBe('icon');
expect(quickBottom.quickLayout).toBe('icon');
expect(Object.keys(quickTop)).toEqual(expect.arrayContaining([
H1HeadingCommand.id,
H2HeadingCommand.id,
H3HeadingCommand.id,
H4HeadingCommand.id,
H5HeadingCommand.id,
OrderListCommand.id,
]));
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();
});
it('builds the edit-state T menu with official submenus instead of a custom panel', () => {
const paragraph = (menuSchema as any)[ContextMenuPosition.PARAGRAPH];
const editMenu = paragraph[DOC_PARAGRAPH_T_EDIT_MENU_ID];
const quickTop = editMenu.quickTop;
const quickBottom = editMenu.quickBottom;
const layout = editMenu.layout;
const format = editMenu.format;
const others = editMenu.others;
expect(quickTop.quickLayout).toBe('icon');
expect(quickBottom.quickLayout).toBe('icon');
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(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();
expect(format[DocCopyCurrentParagraphCommand.id].menuItemFactory).toBeDefined();
expect(format[DeleteCurrentParagraphCommand.id].menuItemFactory).toBeDefined();
expect(others[DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID].menuItemFactory).toBeDefined();
});
it('keeps divider-state T menus compact while reusing the same official submenu system', () => {
const paragraph = (menuSchema as any)[ContextMenuPosition.PARAGRAPH];
const dividerMenu = paragraph[DOC_PARAGRAPH_T_DIVIDER_MENU_ID];
expect(dividerMenu.quick).toBeUndefined();
expect(dividerMenu.layout[DOC_PARAGRAPH_T_COLORS_MENU_ID].menuItemFactory).toBeDefined();
expect(dividerMenu.layout[DOC_PARAGRAPH_T_ALIGN_MENU_ID]).toBeUndefined();
expect(dividerMenu.others[DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID].menuItemFactory).toBeDefined();
});
it('defines align, color, and insert-below submenu contents in schema', () => {
const paragraph = (menuSchema as any)[ContextMenuPosition.PARAGRAPH];
const alignMenu = paragraph[DOC_PARAGRAPH_T_ALIGN_MENU_ID];
const colorsMenu = paragraph[DOC_PARAGRAPH_T_COLORS_MENU_ID];
const insertBelowMenu = paragraph[DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID];
expect(alignMenu.align.title).toBe('docs-ui.paragraphMenu.align');
expect(alignMenu.align.quickLayout).toBe('icon');
expect(Object.keys(alignMenu.align)).toEqual(expect.arrayContaining([
AlignOperationCommand.id,
`${AlignOperationCommand.id}.center`,
`${AlignOperationCommand.id}.right`,
`${AlignOperationCommand.id}.justify`,
]));
expect(alignMenu.indent.title).toBe('docs-ui.paragraphMenu.indent');
expect(alignMenu.indent.quickLayout).toBeUndefined();
expect(Object.keys(alignMenu.indent)).toEqual(expect.arrayContaining([
DOC_PARAGRAPH_T_INDENT_INCREASE_ID,
DOC_PARAGRAPH_T_INDENT_DECREASE_ID,
]));
expect(colorsMenu.text.title).toBe('docs-ui.toolbar.textColor.main');
expect(colorsMenu.text.headerActionMenuItemFactory).toBe(ParagraphMenuTextColorHeaderActionMenuItemFactory);
expect(colorsMenu.text.quickLayout).toBe('icon');
expect(colorsMenu.text.quickColumns).toBe(8);
expect(colorsMenu.text.quickLayoutVariant).toBe('compact');
expect(colorsMenu.text[`${SetInlineFormatTextColorCommand.id}.default`].menuItemFactory).toBe(ParagraphMenuDefaultTextColorMenuItemFactory);
expect(colorsMenu.backgroundTop.title).toBe('docs-ui.toolbar.fillColor.main');
expect(colorsMenu.backgroundTop.headerActionMenuItemFactory).toBe(ParagraphMenuBackgroundColorHeaderActionMenuItemFactory);
expect(colorsMenu.backgroundTop.quickLayout).toBe('icon');
expect(colorsMenu.backgroundTop.quickColumns).toBe(8);
expect(colorsMenu.backgroundTop.quickLayoutVariant).toBe('compact');
expect(colorsMenu.backgroundTop[ResetInlineFormatTextBackgroundColorCommand.id].menuItemFactory).toBeDefined();
expect(colorsMenu.backgroundBottom.quickLayout).toBe('icon');
expect(colorsMenu.backgroundBottom.quickColumns).toBe(8);
expect(colorsMenu.backgroundBottom.quickLayoutVariant).toBe('compact');
expect(colorsMenu.reset).toBeUndefined();
expect(Object.keys(colorsMenu.text).filter((key) => key.startsWith('doc.menu.paragraph-t.text-color.'))).toHaveLength(7);
expect(Object.keys(colorsMenu.backgroundTop).filter((key) => key.startsWith('doc.menu.paragraph-t.background-color.'))).toHaveLength(7);
expect(Object.keys(colorsMenu.backgroundBottom).filter((key) => key.startsWith('doc.menu.paragraph-t.background-color.'))).toHaveLength(8);
expect(insertBelowMenu.quickTop.quickLayout).toBe('icon');
expect(insertBelowMenu.quickBottom.quickLayout).toBe('icon');
expect(Object.keys(insertBelowMenu.quickTop)).toEqual(expect.arrayContaining([
`${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h1`,
`${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h2`,
`${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h3`,
`${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h4`,
`${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h5`,
InsertOrderListBellowCommand.id,
]));
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();
});
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);
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`);
});
});
+509 -20
View File
@@ -19,18 +19,32 @@ import type { IMenuButtonItem, IMenuItem, IMenuSelectorItem } from '@univerjs/ui
import type { ComponentType } from 'react';
import { ICommandService, NamedStyleType, UniverInstanceType } from '@univerjs/core';
import { SetTextSelectionsOperation } from '@univerjs/docs';
import { H1Icon, H2Icon, H3Icon, H4Icon, H5Icon, TextTypeIcon } from '@univerjs/icons';
import {
CalloutIcon,
CodeBlockIcon,
H1Icon,
H2Icon,
H3Icon,
H4Icon,
H5Icon,
MoreLeftIcon,
MoreRightIcon,
QuoteIcon,
ShapeIcon,
TextTypeIcon,
} from '@univerjs/icons';
import { ComponentManager, getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { createElement } from 'react';
import { Observable } from 'rxjs';
import { DocCopyCommand, DocCopyCurrentParagraphCommand, DocCutCurrentParagraphCommand, DocPasteCommand } from '../commands/commands/clipboard.command';
import { DeleteCurrentParagraphCommand } from '../commands/commands/doc-delete.command';
import { HorizontalLineCommand, InsertHorizontalLineBellowCommand } from '../commands/commands/doc-horizontal-line.command';
import { SetInlineFormatFontSizeCommand } from '../commands/commands/inline-format.command';
import { ResetInlineFormatTextBackgroundColorCommand, SetInlineFormatFontSizeCommand, SetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextColorCommand } from '../commands/commands/inline-format.command';
import { BulletListCommand, CheckListCommand, InsertBulletListBellowCommand, InsertCheckListBellowCommand, InsertOrderListBellowCommand, OrderListCommand } from '../commands/commands/list.command';
import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../commands/commands/set-heading.command';
import { DocTableDeleteTableCommand } from '../commands/commands/table/doc-table-delete.command';
import { disableMenuWhenNoDocRange, getParagraphStyleAtCursor } from './menu';
import { DocCreateTableOperation } from '../commands/operations/doc-create-table.operation';
import { BackgroundColorSelectorMenuItemFactory, disableMenuWhenNoDocRange, getParagraphStyleAtCursor, TextColorSelectorMenuItemFactory } from './menu';
const HEADING_MAP: Record<NamedStyleType, ICommand> = {
[NamedStyleType.HEADING_1]: H1HeadingCommand,
@@ -100,6 +114,42 @@ function SubtitleTypeIcon({ className }: { className: string }) {
);
}
function HeaderTextColorIcon({ className, extend }: { className: string; extend?: { colorChannel1?: string } }) {
const color = extend?.colorChannel1 ?? 'currentColor';
return createElement(
'svg',
{
className,
viewBox: '0 0 24 24',
fill: 'none',
'aria-hidden': true,
},
createElement('rect', {
x: 3,
y: 3,
width: 18,
height: 18,
rx: 4,
fill: 'none',
stroke: 'currentColor',
strokeOpacity: 0.16,
}),
createElement('text', {
x: 7.5,
y: 16.5,
fontFamily: 'Arial, sans-serif',
fontSize: 12,
fontWeight: 700,
fill: color,
}, 'A')
);
}
function DefaultTextColorIcon({ className }: { className: string }) {
return TextColorSwatchIcon({ className, color: '#000000' });
}
export const HEADING_ICON_MAP: Record<NamedStyleType, { key: string; component: ComponentType<{ className: string }> }> = {
[NamedStyleType.HEADING_1]: { key: 'H1Icon', component: H1Icon },
[NamedStyleType.HEADING_2]: { key: 'H2Icon', component: H2Icon },
@@ -112,22 +162,6 @@ export const HEADING_ICON_MAP: Record<NamedStyleType, { key: string; component:
[NamedStyleType.NAMED_STYLE_TYPE_UNSPECIFIED]: { key: 'TextTypeIcon', component: TextTypeIcon },
};
export function shouldShowParagraphHeadingOption(headingType: NamedStyleType, currentType = NamedStyleType.NORMAL_TEXT): boolean {
if (headingType === NamedStyleType.HEADING_5) {
return currentType !== NamedStyleType.TITLE && currentType !== NamedStyleType.SUBTITLE;
}
if (headingType === NamedStyleType.TITLE) {
return currentType === NamedStyleType.TITLE;
}
if (headingType === NamedStyleType.SUBTITLE) {
return currentType === NamedStyleType.SUBTITLE;
}
return true;
}
const createHeadingSelectorMenuItemFactory = (headingType: NamedStyleType) => (accessor: IAccessor): IMenuItem => {
const commandService = accessor.get(ICommandService);
const componentManager = accessor.get(ComponentManager);
@@ -141,7 +175,7 @@ const createHeadingSelectorMenuItemFactory = (headingType: NamedStyleType) => (a
type: MenuItemType.BUTTON,
icon: icon.key,
title: HEADING_TITLE_MAP[headingType],
tooltip: 'ui.toolbar.heading.tooltip',
tooltip: HEADING_TITLE_MAP[headingType],
disabled$: disableMenuWhenNoDocRange(accessor),
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
activated$: new Observable((subscriber) => {
@@ -196,6 +230,7 @@ const createEmptyParagraphButtonFactory = (
type: MenuItemType.BUTTON,
icon,
title,
tooltip: title,
};
};
@@ -244,6 +279,7 @@ export const InsertBulletListBellowMenuItemFactory = (accessor: IAccessor): IMen
type: MenuItemType.BUTTON,
icon: 'UnorderIcon',
title: 'docs-ui.rightClick.bulletList',
tooltip: 'docs-ui.rightClick.bulletList',
};
};
@@ -253,6 +289,7 @@ export const InsertOrderListBellowMenuItemFactory = (accessor: IAccessor): IMenu
type: MenuItemType.BUTTON,
icon: 'OrderIcon',
title: 'docs-ui.rightClick.orderList',
tooltip: 'docs-ui.rightClick.orderList',
};
};
@@ -262,6 +299,7 @@ export const InsertCheckListBellowMenuItemFactory = (accessor: IAccessor): IMenu
type: MenuItemType.BUTTON,
icon: 'TodoListDoubleIcon',
title: 'docs-ui.rightClick.checkList',
tooltip: 'docs-ui.rightClick.checkList',
};
};
@@ -271,17 +309,246 @@ export const InsertHorizontalLineBellowMenuItemFactory = (accessor: IAccessor):
type: MenuItemType.BUTTON,
icon: 'ReduceIcon',
title: 'docs-ui.toolbar.horizontalLine',
tooltip: 'docs-ui.toolbar.horizontalLine',
};
};
export const INSERT_BELLOW_MENU_ID = 'doc.menu.insert-bellow';
export const DOC_CONTENT_INSERT_MENU_ID = 'doc.menu.content-insert';
export const DOC_TABLE_BLOCK_MENU_ID = 'doc.menu.table-block';
export const DOC_PARAGRAPH_T_INSERT_MENU_ID = 'doc.menu.paragraph-t.insert';
export const DOC_PARAGRAPH_T_EDIT_MENU_ID = 'doc.menu.paragraph-t.edit';
export const DOC_PARAGRAPH_T_DIVIDER_MENU_ID = 'doc.menu.paragraph-t.divider';
export const DOC_PARAGRAPH_T_ALIGN_MENU_ID = 'doc.menu.paragraph-t.align';
export const DOC_PARAGRAPH_T_COLORS_MENU_ID = 'doc.menu.paragraph-t.colors';
export const DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID = 'doc.menu.paragraph-t.insert-below';
export const DOC_PARAGRAPH_T_RESET_COLORS_ID = 'doc.menu.paragraph-t.reset-colors';
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 = [
'rgba(158, 109, 227, 0.3)',
'rgba(254, 75, 75, 0.3)',
'rgba(255, 140, 81, 0.3)',
'rgba(164, 220, 22, 0.3)',
'rgba(45, 174, 255, 0.3)',
'rgba(58, 96, 247, 0.3)',
'rgba(242, 72, 166, 0.3)',
'rgba(153, 153, 153, 0.3)',
'rgba(158, 109, 227, 0.15)',
'rgba(254, 75, 75, 0.15)',
'rgba(255, 140, 81, 0.15)',
'rgba(164, 220, 22, 0.15)',
'rgba(45, 174, 255, 0.15)',
'rgba(58, 96, 247, 0.15)',
'rgba(242, 72, 166, 0.15)',
];
export function getDocBlockRangeMenuId(blockType: string): string {
return `doc.block-range.${blockType}.menu`;
}
function ensureParagraphMenuIcon(componentManager: ComponentManager, icon: string) {
if (componentManager.get(icon)) {
return;
}
const mapping: Partial<Record<string, ComponentType<{ className: string }>>> = {
TitleTypeIcon,
SubtitleTypeIcon,
DefaultTextColorIcon,
HeaderTextColorIcon,
CodeBlockIcon,
QuoteIcon,
CalloutIcon,
ShapeIcon,
MoreRightIcon,
MoreLeftIcon,
};
const component = mapping[icon];
if (component) {
componentManager.register(icon, component);
}
}
function TextColorSwatchIcon(props: { className?: string; color: string }) {
const { className, color } = props;
return createElement(
'svg',
{
className,
viewBox: '0 0 24 24',
fill: 'none',
'aria-hidden': true,
},
createElement('rect', {
x: 3,
y: 3,
width: 18,
height: 18,
rx: 4,
fill: 'none',
stroke: 'currentColor',
strokeOpacity: 0.16,
}),
createElement('text', {
x: 7.5,
y: 16.5,
fontFamily: 'Arial, sans-serif',
fontSize: 12,
fontWeight: 700,
fill: color,
}, 'A')
);
}
function BackgroundColorSwatchIcon(props: { className?: string; color: string }) {
const { className, color } = props;
return createElement(
'svg',
{
className,
viewBox: '0 0 24 24',
fill: 'none',
'aria-hidden': true,
},
createElement('rect', {
x: 3,
y: 3,
width: 18,
height: 18,
rx: 5,
fill: color,
stroke: 'currentColor',
strokeOpacity: 0.12,
})
);
}
function ensureColorSwatchIcon(componentManager: ComponentManager, icon: string, color: string, variant: 'text' | 'background') {
if (componentManager.get(icon)) {
return;
}
componentManager.register(
icon,
({ className }: { className?: string }) => (variant === 'text'
? TextColorSwatchIcon({ className, color })
: BackgroundColorSwatchIcon({ className, color }))
);
}
function createStaticButtonMenuItemFactory(config: {
id: string;
commandId?: string;
icon?: string;
title?: string;
tooltip?: string;
params?: Record<string, unknown>;
disabled$?: ReturnType<typeof disableMenuWhenNoDocRange>;
}) {
return (accessor: IAccessor): IMenuButtonItem => {
const componentManager = accessor.get(ComponentManager);
if (config.icon) {
ensureParagraphMenuIcon(componentManager, config.icon);
}
return {
id: config.id,
commandId: config.commandId,
type: MenuItemType.BUTTON,
icon: config.icon,
title: config.title,
tooltip: config.tooltip,
params: config.params,
disabled$: config.disabled$,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
};
}
function createStaticSubmenuMenuItemFactory(config: {
id: string;
icon?: string;
title?: string;
tooltip?: string;
}) {
return (accessor: IAccessor): IMenuSelectorItem<string> => {
const componentManager = accessor.get(ComponentManager);
if (config.icon) {
ensureParagraphMenuIcon(componentManager, config.icon);
}
return {
id: config.id,
type: MenuItemType.SUBITEMS,
icon: config.icon,
title: config.title,
tooltip: config.tooltip,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
};
}
function createColorSwatchMenuItemFactory(config: {
id: string;
commandId: string;
icon: string;
color: string;
variant: 'text' | 'background';
}) {
return (accessor: IAccessor): IMenuButtonItem => {
const componentManager = accessor.get(ComponentManager);
ensureColorSwatchIcon(componentManager, config.icon, config.color, config.variant);
return {
id: config.id,
commandId: config.commandId,
type: MenuItemType.BUTTON,
icon: config.icon,
params: {
value: config.color,
},
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
};
}
function createHeaderActionMenuItemFactory(
baseFactory: (accessor: IAccessor) => IMenuItem,
config?: {
icon?: string;
}
) {
return (accessor: IAccessor): IMenuItem => {
const componentManager = accessor.get(ComponentManager);
if (config?.icon) {
ensureParagraphMenuIcon(componentManager, config.icon);
}
const baseItem = baseFactory(accessor) as IMenuSelectorItem<string, string | undefined>;
return {
...baseItem,
icon: config?.icon ?? baseItem.icon,
tooltip: undefined,
title: undefined,
};
};
}
export const TableBlockCopyMenuItemFactory = (accessor: IAccessor): IMenuItem => {
return {
id: DocCopyCommand.name,
@@ -317,3 +584,225 @@ export function DocInsertBellowMenuItemFactory(accessor: IAccessor): IMenuSelect
title: 'docs-ui.rightClick.insertBellow',
};
}
export const ParagraphMenuAlignSubmenuItemFactory = createStaticSubmenuMenuItemFactory({
id: DOC_PARAGRAPH_T_ALIGN_MENU_ID,
icon: 'LeftJustifyingIcon',
title: 'docs-ui.paragraphMenu.alignAndIndent',
tooltip: 'docs-ui.paragraphMenu.alignAndIndent',
});
export const ParagraphMenuColorsSubmenuItemFactory = createStaticSubmenuMenuItemFactory({
id: DOC_PARAGRAPH_T_COLORS_MENU_ID,
icon: 'PaintBucketDoubleIcon',
title: 'docs-ui.paragraphMenu.color',
tooltip: 'docs-ui.paragraphMenu.color',
});
export const ParagraphMenuTextColorHeaderActionMenuItemFactory = createHeaderActionMenuItemFactory(
TextColorSelectorMenuItemFactory,
{ icon: 'HeaderTextColorIcon' }
);
export const ParagraphMenuBackgroundColorHeaderActionMenuItemFactory = createHeaderActionMenuItemFactory(
BackgroundColorSelectorMenuItemFactory,
{ icon: 'PaintBucketDoubleIcon' }
);
export const ParagraphMenuInsertBelowSubmenuItemFactory = createStaticSubmenuMenuItemFactory({
id: DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID,
icon: 'TextTypeIcon',
title: 'docs-ui.rightClick.insertBellow',
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,
icon: 'H1Icon',
title: 'ui.toolbar.heading.1',
tooltip: 'ui.toolbar.heading.1',
params: { commandId: H1HeadingCommand.id, paragraphMenuPlacement: 'below', paragraphMenuInsertMode: 'breakline' },
});
export const ParagraphMenuInsertBelowHeadingH2MenuItemFactory = createStaticButtonMenuItemFactory({
id: `${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h2`,
commandId: DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID,
icon: 'H2Icon',
title: 'ui.toolbar.heading.2',
tooltip: 'ui.toolbar.heading.2',
params: { commandId: H2HeadingCommand.id, paragraphMenuPlacement: 'below', paragraphMenuInsertMode: 'breakline' },
});
export const ParagraphMenuInsertBelowHeadingH3MenuItemFactory = createStaticButtonMenuItemFactory({
id: `${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h3`,
commandId: DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID,
icon: 'H3Icon',
title: 'ui.toolbar.heading.3',
tooltip: 'ui.toolbar.heading.3',
params: { commandId: H3HeadingCommand.id, paragraphMenuPlacement: 'below', paragraphMenuInsertMode: 'breakline' },
});
export const ParagraphMenuInsertBelowHeadingH4MenuItemFactory = createStaticButtonMenuItemFactory({
id: `${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h4`,
commandId: DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID,
icon: 'H4Icon',
title: 'ui.toolbar.heading.4',
tooltip: 'ui.toolbar.heading.4',
params: { commandId: H4HeadingCommand.id, paragraphMenuPlacement: 'below', paragraphMenuInsertMode: 'breakline' },
});
export const ParagraphMenuInsertBelowHeadingH5MenuItemFactory = createStaticButtonMenuItemFactory({
id: `${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h5`,
commandId: DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID,
icon: 'H5Icon',
title: 'ui.toolbar.heading.5',
tooltip: 'ui.toolbar.heading.5',
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,
icon: 'GridIcon',
title: 'docs-ui.toolbar.table.insert',
tooltip: 'docs-ui.toolbar.table.insert',
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',
title: 'docs-ui.paragraphMenu.increase',
tooltip: 'docs-ui.paragraphMenu.increaseIndent',
});
export const ParagraphMenuIndentDecreaseMenuItemFactory = createStaticButtonMenuItemFactory({
id: DOC_PARAGRAPH_T_INDENT_DECREASE_ID,
icon: 'MoreLeftIcon',
title: 'docs-ui.paragraphMenu.decrease',
tooltip: 'docs-ui.paragraphMenu.decreaseIndent',
});
export const ParagraphMenuDefaultTextColorMenuItemFactory = createStaticButtonMenuItemFactory({
id: `${SetInlineFormatTextColorCommand.id}.default`,
commandId: SetInlineFormatTextColorCommand.id,
icon: 'DefaultTextColorIcon',
title: 'docs-ui.paragraphMenu.defaultTextColor',
params: { value: '#000000' },
});
export const ParagraphMenuNoBackgroundMenuItemFactory = createStaticButtonMenuItemFactory({
id: ResetInlineFormatTextBackgroundColorCommand.id,
icon: 'NoColorDoubleIcon',
title: 'docs-ui.paragraphMenu.noBackground',
});
export const ParagraphMenuResetColorsMenuItemFactory = createStaticButtonMenuItemFactory({
id: DOC_PARAGRAPH_T_RESET_COLORS_ID,
icon: 'PaintBucketDoubleIcon',
title: 'docs-ui.toolbar.resetColor',
tooltip: 'docs-ui.toolbar.resetColor',
});
export const ParagraphMenuTextColorSwatchMenuItemFactories = TEXT_COLORS.reduce<Record<string, { order: number; menuItemFactory: ReturnType<typeof createColorSwatchMenuItemFactory> }>>((items, color, index) => {
const id = `doc.menu.paragraph-t.text-color.${index}`;
items[id] = {
order: index,
menuItemFactory: createColorSwatchMenuItemFactory({
id,
commandId: SetInlineFormatTextColorCommand.id,
icon: `DocParagraphTextColorSwatchIcon.${index}`,
color,
variant: 'text',
}),
};
return items;
}, {});
export const ParagraphMenuBackgroundColorSwatchMenuItemFactories = BACKGROUND_COLORS.reduce<Record<string, { order: number; menuItemFactory: ReturnType<typeof createColorSwatchMenuItemFactory> }>>((items, color, index) => {
const id = `doc.menu.paragraph-t.background-color.${index}`;
items[id] = {
order: index,
menuItemFactory: createColorSwatchMenuItemFactory({
id,
commandId: SetInlineFormatTextBackgroundColorCommand.id,
icon: `DocParagraphBackgroundColorSwatchIcon.${index}`,
color,
variant: 'background',
}),
};
return items;
}, {});
+433 -1
View File
@@ -50,7 +50,11 @@ import {
TableInsertMenuItemFactory,
} from './context-menu';
import {
AlignCenterMenuItemFactory,
AlignJustifyMenuItemFactory,
AlignLeftMenuItemFactory,
AlignMenuItemFactory,
AlignRightMenuItemFactory,
BackgroundColorSelectorMenuItemFactory,
BoldMenuItemFactory,
BulletListMenuItemFactory,
@@ -78,7 +82,85 @@ import {
TextColorSelectorMenuItemFactory,
UnderlineMenuItemFactory,
} from './menu';
import { CopyCurrentParagraphMenuItemFactory, CutCurrentParagraphMenuItemFactory, DeleteCurrentParagraphMenuItemFactory, DOC_CONTENT_INSERT_MENU_ID, DOC_TABLE_BLOCK_MENU_ID, DocInsertBellowMenuItemFactory, EMPTY_PARAGRAPH_MENU_ID, EmptyParagraphBulletListMenuItemFactory, EmptyParagraphCheckListMenuItemFactory, EmptyParagraphH1MenuItemFactory, EmptyParagraphH2MenuItemFactory, EmptyParagraphH3MenuItemFactory, EmptyParagraphH4MenuItemFactory, EmptyParagraphH5MenuItemFactory, EmptyParagraphHorizontalLineMenuItemFactory, EmptyParagraphNormalTextMenuItemFactory, EmptyParagraphOrderListMenuItemFactory, H1HeadingMenuItemFactory, H2HeadingMenuItemFactory, H3HeadingMenuItemFactory, H4HeadingMenuItemFactory, H5HeadingMenuItemFactory, INSERT_BELLOW_MENU_ID, InsertBulletListBellowMenuItemFactory, InsertCheckListBellowMenuItemFactory, InsertHorizontalLineBellowMenuItemFactory, InsertOrderListBellowMenuItemFactory, NormalTextHeadingMenuItemFactory, SubtitleHeadingMenuItemFactory, TableBlockCopyMenuItemFactory, TableBlockDeleteMenuItemFactory, TableBlockPasteMenuItemFactory, TitleHeadingMenuItemFactory } from './paragraph-menu';
import {
CopyCurrentParagraphMenuItemFactory,
CutCurrentParagraphMenuItemFactory,
DeleteCurrentParagraphMenuItemFactory,
DOC_CONTENT_INSERT_MENU_ID,
DOC_PARAGRAPH_T_ALIGN_MENU_ID,
DOC_PARAGRAPH_T_COLORS_MENU_ID,
DOC_PARAGRAPH_T_DIVIDER_MENU_ID,
DOC_PARAGRAPH_T_EDIT_MENU_ID,
DOC_PARAGRAPH_T_INDENT_DECREASE_ID,
DOC_PARAGRAPH_T_INDENT_INCREASE_ID,
DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID,
DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID,
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,
EmptyParagraphH1MenuItemFactory,
EmptyParagraphH2MenuItemFactory,
EmptyParagraphH3MenuItemFactory,
EmptyParagraphH4MenuItemFactory,
EmptyParagraphH5MenuItemFactory,
EmptyParagraphHorizontalLineMenuItemFactory,
EmptyParagraphNormalTextMenuItemFactory,
EmptyParagraphOrderListMenuItemFactory,
H1HeadingMenuItemFactory,
H2HeadingMenuItemFactory,
H3HeadingMenuItemFactory,
H4HeadingMenuItemFactory,
H5HeadingMenuItemFactory,
INSERT_BELLOW_MENU_ID,
INSERT_DOC_IMAGE_COMMAND_ID,
INSERT_DOC_SHAPE_COMMAND_ID,
InsertBulletListBellowMenuItemFactory,
InsertCheckListBellowMenuItemFactory,
InsertHorizontalLineBellowMenuItemFactory,
InsertOrderListBellowMenuItemFactory,
NormalTextHeadingMenuItemFactory,
ParagraphMenuAlignSubmenuItemFactory,
ParagraphMenuBackgroundColorHeaderActionMenuItemFactory,
ParagraphMenuBackgroundColorSwatchMenuItemFactories,
ParagraphMenuColorsSubmenuItemFactory,
ParagraphMenuDefaultTextColorMenuItemFactory,
ParagraphMenuIndentDecreaseMenuItemFactory,
ParagraphMenuIndentIncreaseMenuItemFactory,
ParagraphMenuInsertBelowCalloutMenuItemFactory,
ParagraphMenuInsertBelowCodeMenuItemFactory,
ParagraphMenuInsertBelowHeadingH1MenuItemFactory,
ParagraphMenuInsertBelowHeadingH2MenuItemFactory,
ParagraphMenuInsertBelowHeadingH3MenuItemFactory,
ParagraphMenuInsertBelowHeadingH4MenuItemFactory,
ParagraphMenuInsertBelowHeadingH5MenuItemFactory,
ParagraphMenuInsertBelowImageMenuItemFactory,
ParagraphMenuInsertBelowQuoteMenuItemFactory,
ParagraphMenuInsertBelowShapeMenuItemFactory,
ParagraphMenuInsertBelowSubmenuItemFactory,
ParagraphMenuInsertBelowTableMenuItemFactory,
ParagraphMenuInsertCalloutMenuItemFactory,
ParagraphMenuInsertCodeMenuItemFactory,
ParagraphMenuInsertImageMenuItemFactory,
ParagraphMenuInsertQuoteMenuItemFactory,
ParagraphMenuInsertShapeMenuItemFactory,
ParagraphMenuNoBackgroundMenuItemFactory,
ParagraphMenuTextColorHeaderActionMenuItemFactory,
ParagraphMenuTextColorSwatchMenuItemFactories,
SubtitleHeadingMenuItemFactory,
TableBlockCopyMenuItemFactory,
TableBlockDeleteMenuItemFactory,
TableBlockPasteMenuItemFactory,
TitleHeadingMenuItemFactory,
} from './paragraph-menu';
export const floatToolbarMenuSchema: MenuSchemaType = {
[FLOAT_TOOLBAR_MENU_POSITION]: {
@@ -481,5 +563,355 @@ export const menuSchema: MenuSchemaType = {
},
},
},
[DOC_PARAGRAPH_T_INSERT_MENU_ID]: {
quickTop: {
order: 0,
quickLayout: 'icon',
[H1HeadingCommand.id]: {
order: 0,
menuItemFactory: EmptyParagraphH1MenuItemFactory,
},
[H2HeadingCommand.id]: {
order: 1,
menuItemFactory: EmptyParagraphH2MenuItemFactory,
},
[H3HeadingCommand.id]: {
order: 2,
menuItemFactory: EmptyParagraphH3MenuItemFactory,
},
[H4HeadingCommand.id]: {
order: 3,
menuItemFactory: EmptyParagraphH4MenuItemFactory,
},
[H5HeadingCommand.id]: {
order: 4,
menuItemFactory: EmptyParagraphH5MenuItemFactory,
},
[OrderListCommand.id]: {
order: 5,
menuItemFactory: EmptyParagraphOrderListMenuItemFactory,
},
},
quickBottom: {
order: 1,
quickLayout: 'icon',
[BulletListCommand.id]: {
order: 0,
menuItemFactory: EmptyParagraphBulletListMenuItemFactory,
},
[CheckListCommand.id]: {
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,
},
},
insert: {
order: 2,
[DocCreateTableOperation.id]: {
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]: {
quickTop: {
order: 0,
quickLayout: 'icon',
[`${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h1`]: {
order: 0,
menuItemFactory: ParagraphMenuInsertBelowHeadingH1MenuItemFactory,
},
[`${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h2`]: {
order: 1,
menuItemFactory: ParagraphMenuInsertBelowHeadingH2MenuItemFactory,
},
[`${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h3`]: {
order: 2,
menuItemFactory: ParagraphMenuInsertBelowHeadingH3MenuItemFactory,
},
[`${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h4`]: {
order: 3,
menuItemFactory: ParagraphMenuInsertBelowHeadingH4MenuItemFactory,
},
[`${DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID}.h5`]: {
order: 4,
menuItemFactory: ParagraphMenuInsertBelowHeadingH5MenuItemFactory,
},
[InsertOrderListBellowCommand.id]: {
order: 5,
menuItemFactory: InsertOrderListBellowMenuItemFactory,
},
},
quickBottom: {
order: 1,
quickLayout: 'icon',
[InsertBulletListBellowCommand.id]: {
order: 0,
menuItemFactory: InsertBulletListBellowMenuItemFactory,
},
[InsertCheckListBellowCommand.id]: {
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,
},
},
insert: {
order: 2,
[`${DocCreateTableOperation.id}.below`]: {
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]: {
quickTop: {
order: 0,
quickLayout: 'icon',
[NormalTextHeadingCommand.id]: {
order: 0,
menuItemFactory: NormalTextHeadingMenuItemFactory,
},
[H1HeadingCommand.id]: {
order: 1,
menuItemFactory: H1HeadingMenuItemFactory,
},
[H2HeadingCommand.id]: {
order: 2,
menuItemFactory: H2HeadingMenuItemFactory,
},
[H3HeadingCommand.id]: {
order: 3,
menuItemFactory: H3HeadingMenuItemFactory,
},
[H4HeadingCommand.id]: {
order: 4,
menuItemFactory: H4HeadingMenuItemFactory,
},
[H5HeadingCommand.id]: {
order: 5,
menuItemFactory: H5HeadingMenuItemFactory,
},
[TitleHeadingCommand.id]: {
order: 6,
menuItemFactory: TitleHeadingMenuItemFactory,
},
[SubtitleHeadingCommand.id]: {
order: 7,
menuItemFactory: SubtitleHeadingMenuItemFactory,
},
},
quickBottom: {
order: 1,
quickLayout: 'icon',
[OrderListCommand.id]: {
order: 0,
menuItemFactory: OrderListMenuItemFactory,
},
[BulletListCommand.id]: {
order: 1,
menuItemFactory: BulletListMenuItemFactory,
},
[CheckListCommand.id]: {
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,
[DOC_PARAGRAPH_T_ALIGN_MENU_ID]: {
order: 0,
menuItemFactory: ParagraphMenuAlignSubmenuItemFactory,
},
[DOC_PARAGRAPH_T_COLORS_MENU_ID]: {
order: 1,
menuItemFactory: ParagraphMenuColorsSubmenuItemFactory,
},
},
format: {
order: 3,
[DocCutCurrentParagraphCommand.id]: {
order: 0,
menuItemFactory: CutCurrentParagraphMenuItemFactory,
},
[DocCopyCurrentParagraphCommand.id]: {
order: 1,
menuItemFactory: CopyCurrentParagraphMenuItemFactory,
},
[DeleteCurrentParagraphCommand.id]: {
order: 2,
menuItemFactory: DeleteCurrentParagraphMenuItemFactory,
},
},
others: {
order: 4,
[DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID]: {
order: 0,
menuItemFactory: ParagraphMenuInsertBelowSubmenuItemFactory,
},
},
},
[DOC_PARAGRAPH_T_DIVIDER_MENU_ID]: {
layout: {
order: 1,
[DOC_PARAGRAPH_T_COLORS_MENU_ID]: {
order: 0,
menuItemFactory: ParagraphMenuColorsSubmenuItemFactory,
},
},
format: {
order: 2,
[DocCutCurrentParagraphCommand.id]: {
order: 0,
menuItemFactory: CutCurrentParagraphMenuItemFactory,
},
[DocCopyCurrentParagraphCommand.id]: {
order: 1,
menuItemFactory: CopyCurrentParagraphMenuItemFactory,
},
[DeleteCurrentParagraphCommand.id]: {
order: 2,
menuItemFactory: DeleteCurrentParagraphMenuItemFactory,
},
},
others: {
order: 3,
[DOC_PARAGRAPH_T_INSERT_BELOW_MENU_ID]: {
order: 0,
menuItemFactory: ParagraphMenuInsertBelowSubmenuItemFactory,
},
},
},
[DOC_PARAGRAPH_T_ALIGN_MENU_ID]: {
align: {
order: 0,
title: 'docs-ui.paragraphMenu.align',
quickLayout: 'icon',
[AlignOperationCommand.id]: {
order: 0,
menuItemFactory: AlignLeftMenuItemFactory,
},
[`${AlignOperationCommand.id}.center`]: {
order: 1,
menuItemFactory: AlignCenterMenuItemFactory,
},
[`${AlignOperationCommand.id}.right`]: {
order: 2,
menuItemFactory: AlignRightMenuItemFactory,
},
[`${AlignOperationCommand.id}.justify`]: {
order: 3,
menuItemFactory: AlignJustifyMenuItemFactory,
},
},
indent: {
order: 1,
title: 'docs-ui.paragraphMenu.indent',
[DOC_PARAGRAPH_T_INDENT_INCREASE_ID]: {
order: 0,
menuItemFactory: ParagraphMenuIndentIncreaseMenuItemFactory,
},
[DOC_PARAGRAPH_T_INDENT_DECREASE_ID]: {
order: 1,
menuItemFactory: ParagraphMenuIndentDecreaseMenuItemFactory,
},
},
},
[DOC_PARAGRAPH_T_COLORS_MENU_ID]: {
text: {
order: 0,
title: 'docs-ui.toolbar.textColor.main',
headerActionMenuItemFactory: ParagraphMenuTextColorHeaderActionMenuItemFactory,
quickLayout: 'icon',
quickColumns: 8,
quickLayoutVariant: 'compact',
[`${SetInlineFormatTextColorCommand.id}.default`]: {
order: 0,
menuItemFactory: ParagraphMenuDefaultTextColorMenuItemFactory,
},
...ParagraphMenuTextColorSwatchMenuItemFactories,
},
backgroundTop: {
order: 1,
title: 'docs-ui.toolbar.fillColor.main',
headerActionMenuItemFactory: ParagraphMenuBackgroundColorHeaderActionMenuItemFactory,
quickLayout: 'icon',
quickColumns: 8,
quickLayoutVariant: 'compact',
[ResetInlineFormatTextBackgroundColorCommand.id]: {
order: 0,
menuItemFactory: ParagraphMenuNoBackgroundMenuItemFactory,
},
...Object.fromEntries(
Object.entries(ParagraphMenuBackgroundColorSwatchMenuItemFactories).filter(([, config]) => config.order < 7)
),
},
backgroundBottom: {
order: 2,
quickLayout: 'icon',
quickColumns: 8,
quickLayoutVariant: 'compact',
...Object.fromEntries(
Object.entries(ParagraphMenuBackgroundColorSwatchMenuItemFactories).filter(([, config]) => config.order >= 7)
),
},
},
},
};
@@ -250,12 +250,13 @@ describe('DocParagraphMenuService', () => {
it('projects table cell block-menu geometry through the horizontal table viewport', () => {
expect(getTableHorizontalViewportGeometry(100, 600, {
contentWidth: 600,
leadingInsetLeft: 80,
scrollLeft: 160,
viewportWidth: 240,
})).toEqual({
scrollLeft: 160,
visibleLeft: 100,
visibleRight: 340,
visibleLeft: 20,
visibleRight: 260,
});
});
@@ -231,15 +231,23 @@ export function getTableBlockMenuHoverRect(tableRect: IBoundRectNoAngle): IBound
}
export function getTableHorizontalViewportGeometry(tableLeft: number, tableWidth: number, viewport: Nullable<IDocsTableRenderViewport>) {
const hasHorizontalViewport = viewport != null && viewport.contentWidth > viewport.viewportWidth;
const hasHorizontalViewport = hasHorizontalTableViewport(viewport);
const visibleLeft = hasHorizontalViewport
? tableLeft - (viewport.leadingInsetLeft ?? 0)
: tableLeft;
return {
scrollLeft: hasHorizontalViewport ? viewport.scrollLeft : 0,
visibleLeft: tableLeft,
visibleRight: tableLeft + (hasHorizontalViewport ? viewport.viewportWidth : tableWidth),
visibleLeft,
visibleRight: visibleLeft + (hasHorizontalViewport ? viewport.viewportWidth : tableWidth),
};
}
function hasHorizontalTableViewport(viewport: Nullable<IDocsTableRenderViewport>): viewport is IDocsTableRenderViewport {
return viewport != null &&
(viewport.leadingInsetLeft ?? 0) + viewport.contentWidth + (viewport.trailingInsetRight ?? 0) > viewport.viewportWidth;
}
export class DocEventManagerService extends Disposable implements IRenderModule {
private readonly _hoverCustomRanges$ = new BehaviorSubject<ICustomRangeActive[]>([]);
readonly hoverCustomRanges$ = this._hoverCustomRanges$.pipe(distinctUntilChanged((pre, aft) => pre.length === aft.length && pre.every((item, i) => aft[i].range.rangeId === item.range.rangeId && aft[i].segmentId === item.segmentId && aft[i].segmentPageIndex === item.segmentPageIndex && aft[i].range.startIndex === item.range.startIndex)));
@@ -443,6 +443,7 @@ export class DocParagraphMenuService extends Disposable implements IRenderModule
const paragraphDataStream = dataStream.slice(paragraph.paragraphStart, paragraph.paragraphEnd);
const paragraphModel = body?.paragraphs?.find((item) => item.startIndex === paragraph.startIndex);
const listIcon = getListIcon(paragraphModel?.bullet?.listType);
const isHorizontalRuleParagraph = paragraphDataStream.replace(/[\r\n]/g, '') === '' && !!paragraphModel?.paragraphStyle?.borderBottom;
const customBlock = body?.customBlocks?.find((item) => item.startIndex >= paragraph.paragraphStart && item.startIndex <= paragraph.paragraphEnd);
const isCustomBlockOnly = customBlock?.blockType === BlockType.CUSTOM && paragraphDataStream.replace(/[\b\r\n]/g, '') === '';
if (customBlock && customBlock.blockType === BlockType.CUSTOM && isCustomBlockOnly) {
@@ -472,7 +473,7 @@ export class DocParagraphMenuService extends Disposable implements IRenderModule
kind: 'paragraph',
key: `paragraph:${paragraph.startIndex}`,
paragraph,
icon: listIcon,
icon: isHorizontalRuleParagraph ? 'ReduceIcon' : listIcon,
cellRange: cellRange ?? undefined,
menuRange: {
startOffset: paragraph.paragraphStart,
@@ -480,7 +481,7 @@ export class DocParagraphMenuService extends Disposable implements IRenderModule
collapsed: true,
},
moveRange: getParagraphMoveRange(this._context.unit, paragraph, cellRange),
emptyMode: paragraphDataStream.replace(/[\r\n]/g, '') === '',
emptyMode: isHorizontalRuleParagraph ? false : paragraphDataStream.replace(/[\r\n]/g, '') === '',
draggable: cellRange != null || !inTable,
};
}
@@ -204,12 +204,13 @@ function pushViewportClippedPoints(
viewport: Nullable<IDocsTableRenderViewport>,
tableLeft: number
): void {
const scrollLeft = viewport && viewport.contentWidth > viewport.viewportWidth ? viewport.scrollLeft : 0;
const viewportWidth = viewport && viewport.contentWidth > viewport.viewportWidth ? viewport.viewportWidth : null;
const scrollLeft = hasHorizontalTableViewport(viewport) ? viewport.scrollLeft : 0;
const viewportWidth = hasHorizontalTableViewport(viewport) ? viewport.viewportWidth : null;
const visibleLeft = tableLeft - (viewport?.leadingInsetLeft ?? 0);
const startX = position.startX - scrollLeft;
const endX = position.endX - scrollLeft;
const clippedStartX = viewportWidth == null ? startX : Math.max(startX, tableLeft);
const clippedEndX = viewportWidth == null ? endX : Math.min(endX, tableLeft + viewportWidth);
const clippedStartX = viewportWidth == null ? startX : Math.max(startX, visibleLeft);
const clippedEndX = viewportWidth == null ? endX : Math.min(endX, visibleLeft + viewportWidth);
if (clippedEndX <= clippedStartX) {
return;
@@ -222,6 +223,11 @@ function pushViewportClippedPoints(
}));
}
function hasHorizontalTableViewport(viewport: Nullable<IDocsTableRenderViewport>): viewport is IDocsTableRenderViewport {
return viewport != null &&
(viewport.leadingInsetLeft ?? 0) + viewport.contentWidth + (viewport.trailingInsetRight ?? 0) > viewport.viewportWidth;
}
interface IRectRangeNodePositions {
anchor: INodePosition;
focus: INodePosition;
@@ -17,6 +17,7 @@
import type { IPosition, ITextRange, Nullable } from '@univerjs/core';
import type {
DocumentSkeleton,
IDocsTableRenderViewport,
IDocumentOffsetConfig,
IDocumentSkeletonColumn,
IDocumentSkeletonDivide,
@@ -585,11 +586,11 @@ export class NodePositionConvertToCursor {
const { top: rowTop } = rowSke;
const sourceTableId = getTableIdAndSliceIndex(tableSke.tableId).tableId;
const viewport = getDocsTableRenderViewport(getDocumentUnitId(skeleton), sourceTableId);
const hasHorizontalViewport = viewport && viewport.contentWidth > viewport.viewportWidth;
const hasHorizontalViewport = hasHorizontalTableViewport(viewport);
const scrollLeft = hasHorizontalViewport ? viewport.scrollLeft : 0;
if (hasHorizontalViewport) {
const visibleLeft = this._liquid.x + tableLeft;
const visibleLeft = this._liquid.x + tableLeft - (viewport.leadingInsetLeft ?? 0);
this._horizontalClip = {
left: visibleLeft,
right: visibleLeft + viewport.viewportWidth,
@@ -688,6 +689,11 @@ export class NodePositionConvertToCursor {
}
}
function hasHorizontalTableViewport(viewport: Nullable<IDocsTableRenderViewport>): viewport is IDocsTableRenderViewport {
return viewport != null &&
(viewport.leadingInsetLeft ?? 0) + viewport.contentWidth + (viewport.trailingInsetRight ?? 0) > viewport.viewportWidth;
}
function clipPositionToHorizontalRange(position: IPosition, clip: Nullable<{ left: number; right: number }>): Nullable<IPosition> {
if (!clip) {
return position;
@@ -723,6 +723,7 @@ describe('documents render', () => {
return {
contentWidth: 240,
leadingInsetLeft: 40,
scrollLeft: 80,
viewportWidth: 120,
};
@@ -762,7 +763,7 @@ describe('documents render', () => {
expect(queriedUnitIds).toEqual(['doc-unit-1:table-1']);
expect(ctx.clip).toHaveBeenCalledTimes(1);
expect(ctx.rectByPrecision).toHaveBeenCalledWith(20, 30, 124, 64);
expect(ctx.rectByPrecision).toHaveBeenCalledWith(-20, 30, 124, 64);
expect(translateCalls).toContainEqual([-80, 0]);
documents.dispose();
@@ -38,7 +38,7 @@ import { DocComponent } from './doc-component';
import { DOCS_EXTENSION_TYPE } from './doc-extension';
import { getTableIdAndSliceIndex } from './layout/block/table';
import { Liquid } from './liquid';
import { getDocsTableRenderViewport } from './table-render-viewport';
import { getDocsTableRenderViewport, hasDocsTableHorizontalViewport } from './table-render-viewport';
import './extensions';
const DEFAULT_BORDER_COLOR: ITableCellBorder = {
@@ -538,12 +538,13 @@ export class Documents extends DocComponent {
drawLiquid.translateSave();
drawLiquid.translate(tableLeft, tableTop);
if (viewport && viewport.contentWidth > viewport.viewportWidth) {
if (hasDocsTableHorizontalViewport(viewport)) {
const { x, y } = drawLiquid;
const clipLeft = x + page.marginLeft - (viewport.leadingInsetLeft ?? 0);
ctx.save();
ctx.beginPath();
ctx.rectByPrecision(
x + page.marginLeft - TABLE_VIEWPORT_BORDER_CLIP_PADDING,
clipLeft - TABLE_VIEWPORT_BORDER_CLIP_PADDING,
y + page.marginTop - TABLE_VIEWPORT_BORDER_CLIP_PADDING,
viewport.viewportWidth + TABLE_VIEWPORT_BORDER_CLIP_PADDING * 2,
tableSkeleton.height + TABLE_VIEWPORT_BORDER_CLIP_PADDING * 2
@@ -587,7 +588,7 @@ export class Documents extends DocComponent {
drawLiquid.translateRestore();
}
if (viewport && viewport.contentWidth > viewport.viewportWidth) {
if (hasDocsTableHorizontalViewport(viewport)) {
ctx.restore();
}
@@ -600,7 +601,7 @@ export class Documents extends DocComponent {
tableSkeleton: IDocumentSkeletonTable,
unitId: string,
tableId: string
): Nullable<IDocsTableRenderViewport> {
): IDocsTableRenderViewport | null {
const viewport = getDocsTableRenderViewport(unitId, tableId);
if (viewport) {
return viewport;
@@ -29,7 +29,7 @@ import { PRESET_LIST_TYPE, SectionType, Skeleton } from '@univerjs/core';
import { Subject } from 'rxjs';
import { DocumentSkeletonPageType, GlyphType, LineType, PageLayoutType } from '../../../basics/i-document-skeleton-cached';
import { Liquid } from '../liquid';
import { getDocsTableRenderViewport } from '../table-render-viewport';
import { getDocsTableRenderViewport, hasDocsTableHorizontalViewport } from '../table-render-viewport';
import { DocumentEditArea } from '../view-model/document-view-model';
import { dealWithSection } from './block/section';
import { getTableIdAndSliceIndex } from './block/table';
@@ -894,8 +894,8 @@ export class DocumentSkeleton extends Skeleton {
this._findLiquid?.translateSave();
this._findLiquid?.translate(tableLeft, tableTop);
if (viewport && viewport.contentWidth > viewport.viewportWidth) {
const visibleLeft = this._findLiquid.x;
if (hasDocsTableHorizontalViewport(viewport)) {
const visibleLeft = this._findLiquid.x + page.marginLeft - (viewport.leadingInsetLeft ?? 0);
const visibleRight = visibleLeft + viewport.viewportWidth;
if (x < visibleLeft || x > visibleRight) {
this._findLiquid?.translateRestore();
@@ -16,7 +16,9 @@
export interface IDocsTableRenderViewport {
contentWidth: number;
leadingInsetLeft?: number;
scrollLeft: number;
trailingInsetRight?: number;
viewportWidth: number;
}
@@ -31,3 +33,11 @@ export function setDocsTableRenderViewportProvider(provider: DocsTableRenderView
export function getDocsTableRenderViewport(unitId: string, tableId: string): IDocsTableRenderViewport | null {
return docsTableRenderViewportProvider?.(unitId, tableId) ?? null;
}
export function getDocsTableVirtualContentWidth(viewport: IDocsTableRenderViewport): number {
return (viewport.leadingInsetLeft ?? 0) + viewport.contentWidth + (viewport.trailingInsetRight ?? 0);
}
export function hasDocsTableHorizontalViewport(viewport: IDocsTableRenderViewport | null | undefined): viewport is IDocsTableRenderViewport {
return viewport != null && getDocsTableVirtualContentWidth(viewport) > viewport.viewportWidth;
}
@@ -17,6 +17,9 @@
import type { ComponentType } from 'react';
import { clsx, Tooltip } from '@univerjs/design';
export type TinyMenuSizeVariant = 'default' | 'paragraph-t';
export type TinyMenuLayoutVariant = 'default' | 'compact';
export interface ITinyMenuItem {
onClick: () => void;
className: string;
@@ -29,33 +32,69 @@ export interface ITinyMenuItem {
export interface ITinyMenuGroupProps {
items: ITinyMenuItem[];
columns?: number;
sizeVariant?: TinyMenuSizeVariant;
layoutVariant?: TinyMenuLayoutVariant;
}
export function DesignTinyMenuGroup({ items }: ITinyMenuGroupProps) {
export function DesignTinyMenuGroup({ items, columns, sizeVariant = 'default', layoutVariant = 'default' }: ITinyMenuGroupProps) {
const isParagraphTVariant = sizeVariant === 'paragraph-t';
const isCompactParagraphVariant = isParagraphTVariant && layoutVariant === 'compact';
return (
<div
className="univer-menu-item-group univer-flex univer-flex-wrap univer-gap-2.5 univer-p-1 univer-px-0"
className={clsx(
'univer-menu-item-group univer-px-0',
isCompactParagraphVariant
? 'univer-gap-0.5 univer-p-0'
: isParagraphTVariant
? 'univer-gap-2 univer-p-1'
: 'univer-gap-1.5 univer-p-0.5',
columns
? `
univer-grid
${isCompactParagraphVariant ? 'univer-justify-items-start' : 'univer-justify-items-center'}
${columns === 6 && !isCompactParagraphVariant ? 'univer-grid-cols-6' : ''}
`
: 'univer-flex univer-flex-wrap'
)}
style={isCompactParagraphVariant && columns
? { gridTemplateColumns: `repeat(${columns}, max-content)` }
: undefined}
>
{items.map((item) => {
const ele = (
<div
key={item.key}
className={clsx(`
univer-flex univer-size-6 univer-cursor-pointer univer-items-center univer-justify-center
univer-rounded-md
hover:univer-bg-gray-50
dark:hover:!univer-bg-gray-900
`, {
'univer-bg-gray-50 dark:!univer-bg-gray-900': item.active,
}, item.className)}
className={clsx(
`
univer-flex univer-cursor-pointer univer-items-center univer-justify-center
hover:univer-bg-gray-50
dark:hover:!univer-bg-gray-900
`,
isCompactParagraphVariant
? 'univer-size-6 univer-rounded-sm'
: isParagraphTVariant
? 'univer-size-8 univer-rounded-lg'
: 'univer-size-6 univer-rounded-md',
{
'univer-bg-gray-50 dark:!univer-bg-gray-900': item.active,
},
item.className
)}
onClick={() => item.onClick()}
>
<item.Icon
className={clsx(
`
univer-size-4 univer-text-gray-900
univer-text-gray-900
dark:!univer-text-gray-200
`,
isCompactParagraphVariant
? 'univer-size-4'
: isParagraphTVariant
? 'univer-size-5'
: 'univer-size-4',
item.iconClassName
)}
/>
@@ -14,8 +14,9 @@
* limitations under the License.
*/
import type { IDisplayMenuItem, IMenuItem, IValueOption } from '../../../services/menu/menu';
import type { IDisplayMenuItem, IMenuItem, IValueOption, MenuItemDefaultValueType } from '../../../services/menu/menu';
import type { IMenuSchema } from '../../../services/menu/menu-manager.service';
import type { TinyMenuLayoutVariant, TinyMenuSizeVariant } from './DesignTinyMenuGroup';
import { convertObservableToBehaviorSubject, LocaleService } from '@univerjs/core';
import { clsx } from '@univerjs/design';
import { useEffect, useMemo, useState } from 'react';
@@ -28,6 +29,9 @@ interface IUIQuickMenuGroupProps {
item: IMenuSchema;
activeItemIds?: string[];
hiddenItemIds?: string[];
columns?: number;
sizeVariant?: TinyMenuSizeVariant;
layoutVariant?: TinyMenuLayoutVariant;
onOptionSelect?: (option: IValueOption) => void;
}
@@ -39,6 +43,10 @@ interface IUIQuickTileMenuItemProps {
const EMPTY_HIDDEN_ITEM_IDS: string[] = [];
type TinyMenuDisplayItem = IDisplayMenuItem<IMenuItem> & {
value?: MenuItemDefaultValueType;
};
export function resolveMenuItemActiveState(itemId: string | undefined, observableActive: boolean, activeItemIds?: string[]): boolean {
if (activeItemIds) {
return Boolean(itemId && activeItemIds.includes(itemId));
@@ -60,7 +68,7 @@ function QuickTileMenuItem(props: IUIQuickTileMenuItemProps) {
const { menuSchema, activeItemIds, onOptionSelect } = props;
const componentManager = useDependency(ComponentManager);
const localeService = useDependency(LocaleService);
const menuItem = menuSchema.item as IDisplayMenuItem<IMenuItem> | undefined;
const menuItem = menuSchema.item as TinyMenuDisplayItem | undefined;
const disabled = useObservable<boolean>(menuItem?.disabled$, false);
const hidden = useObservable<boolean>(menuItem?.hidden$, false);
const activated = useObservable<boolean>(menuItem?.activated$, false);
@@ -105,6 +113,8 @@ function QuickTileMenuItem(props: IUIQuickTileMenuItemProps) {
label: menuItem.id ?? menuSchema.key,
commandId: menuItem.commandId,
id: menuItem.id,
params: menuItem.params,
value: menuItem.value,
tooltip: menuItem.tooltip && localeService.t(menuItem.tooltip),
});
}}
@@ -125,7 +135,7 @@ function QuickTileMenuItem(props: IUIQuickTileMenuItemProps) {
}
export function UITinyMenuGroup(props: IUIQuickMenuGroupProps) {
const { item, activeItemIds, hiddenItemIds = EMPTY_HIDDEN_ITEM_IDS, onOptionSelect } = props;
const { item, activeItemIds, hiddenItemIds = EMPTY_HIDDEN_ITEM_IDS, columns, sizeVariant = 'default', layoutVariant = 'default', onOptionSelect } = props;
const [activeItems, setActiveItems] = useState<string[]>([]);
const [hiddenItems, setHiddenItems] = useState<string[]>([]);
const componentManager = useDependency(ComponentManager);
@@ -184,6 +194,9 @@ export function UITinyMenuGroup(props: IUIQuickMenuGroupProps) {
return (
<DesignTinyMenuGroup
columns={columns}
sizeVariant={sizeVariant}
layoutVariant={layoutVariant}
items={visibleChildren.map((child) => ({
key: child.key,
onClick: () => {
@@ -191,13 +204,18 @@ export function UITinyMenuGroup(props: IUIQuickMenuGroupProps) {
label: child.item?.id ?? child.key,
commandId: child.item?.commandId,
id: child.item?.id,
params: child.item?.params,
value: (child.item as TinyMenuDisplayItem | undefined)?.value,
tooltip: child.item?.tooltip && localeService.t(child.item?.tooltip),
});
},
className: '',
iconClassName: child.item?.icon === 'TextTypeIcon' ? '!univer-size-3.5' : undefined,
iconClassName: child.item?.icon === 'TextTypeIcon'
? (sizeVariant === 'paragraph-t' ? '!univer-size-4' : '!univer-size-3.5')
: undefined,
Icon: componentManager.get(child.item!.icon as string)!,
active: resolveMenuItemActiveState(child.item?.id, activeItems.includes(child.item?.id ?? ''), activeItemIds),
tooltip: child.item?.tooltip ? localeService.t(child.item.tooltip) : undefined,
}))}
/>
);
@@ -0,0 +1,49 @@
/**
* 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 { render } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } 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 });
const { container } = render(
<DesignTinyMenuGroup
columns={8}
sizeVariant="paragraph-t"
layoutVariant="compact"
items={[{
key: 'swatch',
onClick: () => {},
className: '',
Icon,
}]}
/>
);
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-4');
});
});
@@ -14,10 +14,64 @@
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { getVisibleTinyMenuChildren, resolveMenuItemActiveState } from '../TinyMenuGroup';
import { render } from '@testing-library/react';
import { LocaleService } from '@univerjs/core';
import React from 'react';
import { BehaviorSubject, of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ComponentManager } from '../../../../common';
import { MenuItemType } from '../../../../services/menu/menu';
import { getVisibleTinyMenuChildren, resolveMenuItemActiveState, UITinyMenuGroup } from '../TinyMenuGroup';
const dependencyMap = new Map();
const designTinyMenuGroupSpy = vi.fn();
vi.mock('../../../../utils/di', async () => {
const ReactModule = await import('react');
return {
useDependency(token: unknown) {
return dependencyMap.get(token);
},
useObservable<T>(observable: any, defaultValue?: T) {
const [value, setValue] = ReactModule.useState(defaultValue);
ReactModule.useEffect(() => {
if (!observable) {
return;
}
const source = typeof observable === 'function' ? observable() : observable;
const sub = source.subscribe?.((nextValue: T) => setValue(nextValue));
return () => sub?.unsubscribe?.();
}, [observable]);
return value;
},
};
});
vi.mock('../DesignTinyMenuGroup', () => ({
DesignTinyMenuGroup: (props: unknown) => {
designTinyMenuGroupSpy(props);
return React.createElement('div', { 'data-testid': 'design-tiny-menu-group' });
},
}));
describe('TinyMenuGroup', () => {
beforeEach(() => {
dependencyMap.clear();
designTinyMenuGroupSpy.mockClear();
dependencyMap.set(ComponentManager, {
get: () => () => React.createElement('span'),
});
dependencyMap.set(LocaleService, {
t: (key: string) => `translated:${key}`,
direction$: new BehaviorSubject<'ltr'>('ltr'),
});
});
it('uses explicit active items as an override instead of merging with observable active state', () => {
expect(resolveMenuItemActiveState('h1', true, ['normal-text'])).toBe(false);
expect(resolveMenuItemActiveState('normal-text', false, ['normal-text'])).toBe(true);
@@ -33,4 +87,94 @@ describe('TinyMenuGroup', () => {
expect(getVisibleTinyMenuChildren(children, ['title', 'subtitle']).map((item) => item.key)).toEqual(['h1']);
});
it('passes translated tooltips and command params through tiny icon menu items', () => {
const onOptionSelect = vi.fn();
const item = {
key: 'quick',
order: 0,
children: [{
key: 'text-color',
order: 0,
item: {
id: 'doc.command.set-inline-format-text-color',
type: MenuItemType.BUTTON,
icon: 'TextTypeIcon',
tooltip: 'docs-ui.toolbar.textColor.main',
params: { value: '#FE4B4B' },
hidden$: of(false),
activated$: of(false),
},
}],
} as never;
render(React.createElement(UITinyMenuGroup, { item, onOptionSelect }));
const props = designTinyMenuGroupSpy.mock.calls[0][0] as {
items: Array<{ onClick: () => void; tooltip?: string }>;
};
expect(props.items[0].tooltip).toBe('translated:docs-ui.toolbar.textColor.main');
props.items[0].onClick();
expect(onOptionSelect).toHaveBeenCalledWith(expect.objectContaining({
id: 'doc.command.set-inline-format-text-color',
label: 'doc.command.set-inline-format-text-color',
params: { value: '#FE4B4B' },
tooltip: 'translated:docs-ui.toolbar.textColor.main',
}));
});
it('passes fixed column layout through to the tiny menu renderer', () => {
const item = {
key: 'quick',
order: 0,
children: [{
key: 'h1',
order: 0,
item: {
id: 'doc.command.h1-heading',
type: MenuItemType.BUTTON,
icon: 'H1Icon',
hidden$: of(false),
activated$: of(false),
},
}],
} as never;
render(React.createElement(UITinyMenuGroup, { item, columns: 6 }));
const props = designTinyMenuGroupSpy.mock.calls[0][0] as {
columns?: number;
};
expect(props.columns).toBe(6);
});
it('passes paragraph T size variant through to the tiny menu renderer', () => {
const item = {
key: 'quick',
order: 0,
children: [{
key: 'h1',
order: 0,
item: {
id: 'doc.command.h1-heading',
type: MenuItemType.BUTTON,
icon: 'H1Icon',
hidden$: of(false),
activated$: of(false),
},
}],
} as never;
render(React.createElement(UITinyMenuGroup, { item, sizeVariant: 'paragraph-t' }));
const props = designTinyMenuGroupSpy.mock.calls[0][0] as {
sizeVariant?: string;
};
expect(props.sizeVariant).toBe('paragraph-t');
});
});
@@ -25,6 +25,7 @@ import { ContextMenuGroup, ContextMenuPosition, MenuManagerPosition, RibbonDataG
export const IMenuManagerService = createIdentifier<IMenuManagerService>('univer.menu-manager-service');
export type ContextMenuQuickLayout = 'icon' | 'tile';
export type ContextMenuQuickLayoutVariant = 'default' | 'compact';
export interface IMenuSchema {
key: string;
@@ -32,8 +33,11 @@ export interface IMenuSchema {
title?: string;
contextual?: boolean;
item?: IMenuItem;
headerActionItem?: IMenuItem;
children?: IMenuSchema[];
quickLayout?: ContextMenuQuickLayout;
quickColumns?: number;
quickLayoutVariant?: ContextMenuQuickLayoutVariant;
tiny?: boolean;
}
@@ -52,9 +56,12 @@ export interface IMenuManagerService {
export type MenuSchemaType = {
order?: number;
menuItemFactory?: (accessor: IAccessor) => IMenuItem;
headerActionMenuItemFactory?: (accessor: IAccessor) => IMenuItem;
title?: string;
contextual?: boolean;
quickLayout?: ContextMenuQuickLayout;
quickColumns?: number;
quickLayoutVariant?: ContextMenuQuickLayoutVariant;
tiny?: boolean;
} | {
[key: string]: MenuSchemaType;
@@ -294,6 +301,8 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic
title: value.title,
contextual: value.contextual,
quickLayout: value.quickLayout,
quickColumns: value.quickColumns,
quickLayoutVariant: value.quickLayoutVariant,
tiny: value.tiny,
};
@@ -311,6 +320,9 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic
}
}
}
if (value.headerActionMenuItemFactory) {
menuItem.headerActionItem = this._injector.invoke(value.headerActionMenuItemFactory);
}
if (typeof value === 'object') {
const children = this._buildMenuSchema(value);
if (children.length > 0) {
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import type { Dispatch, SetStateAction } from 'react';
import type {
IDisplayMenuItem,
IMenuButtonItem,
@@ -37,12 +38,15 @@ import { MenuItemType } from '../../../services/menu/menu';
import { IMenuManagerService } from '../../../services/menu/menu-manager.service';
import { useDependency, useObservable } from '../../../utils/di';
type ContextMenuSizeVariant = 'default' | 'paragraph-t';
interface IContextMenuPanelProps {
menuType: string;
menuSessionVersion?: number;
className?: string;
activeItemIds?: string[];
hiddenItemIds?: string[];
sizeVariant?: ContextMenuSizeVariant;
onOptionSelect?: (option: IValueOption) => void;
}
@@ -53,6 +57,7 @@ interface IContextMenuMenuProps {
maxMenuHeight: number;
activeItemIds?: string[];
hiddenItemIds?: string[];
sizeVariant: ContextMenuSizeVariant;
onOptionSelect?: (option: IValueOption) => void;
}
@@ -62,18 +67,29 @@ interface IContextMenuMenuItemProps {
menuSessionVersion: number;
submenuPortalContainer: HTMLElement | null;
maxMenuHeight: number;
activeSubmenuKey: string | null;
setActiveSubmenuKey: Dispatch<SetStateAction<string | null>>;
activeItemIds?: string[];
hiddenItemIds?: string[];
compact?: boolean;
headerAction?: boolean;
sizeVariant: ContextMenuSizeVariant;
onOptionSelect?: (option: IValueOption) => void;
}
const contentClassName = 'univer-inline-flex univer-items-center univer-gap-2';
interface IContextMenuSchemaRenderGroup {
startIndex: number;
endIndex: number;
menuSchemas: IMenuSchema[];
}
const menuViewportPadding = 8;
const submenuOverlapOffset = 2;
const submenuVisualGap = 20;
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']);
type MenuLabel = IMenuItem['label'] | IValueOption['label'];
@@ -97,8 +113,181 @@ export function hasRenderableContextMenuSchema(menuSchema: IMenuSchema): boolean
return menuSchema.children.some((childSchema) => Boolean(childSchema.item));
}
export function shouldShowContextMenuGroupSeparator(visibleSchemas: IMenuSchema[], index: number): boolean {
if (index === visibleSchemas.length - 1) {
return false;
}
const currentSchema = visibleSchemas[index];
const nextSchema = visibleSchemas[index + 1];
if (
currentSchema?.quickLayout
&& nextSchema?.quickLayout
&& CONTEXT_MENU_CONNECTED_QUICK_GROUP_KEYS.has(currentSchema.key)
&& CONTEXT_MENU_CONNECTED_QUICK_GROUP_KEYS.has(nextSchema.key)
) {
return false;
}
return true;
}
export function getContextMenuQuickGroupColumns(menuSchema: IMenuSchema): number | undefined {
if (isRealNum(menuSchema.quickColumns)) {
return menuSchema.quickColumns;
}
if (menuSchema.quickLayout === 'icon' && CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS.has(menuSchema.key)) {
return 6;
}
return undefined;
}
function getContextMenuContentClassName(sizeVariant: ContextMenuSizeVariant) {
return clsx(
'univer-inline-flex univer-items-center',
sizeVariant === 'paragraph-t' ? 'univer-gap-3' : 'univer-gap-2'
);
}
function getContextMenuPanelClassName(sizeVariant: ContextMenuSizeVariant) {
return sizeVariant === 'paragraph-t'
? `
univer-box-border univer-grid univer-min-w-64 univer-max-w-full univer-gap-2 univer-overflow-y-auto
univer-overscroll-contain univer-rounded-md univer-bg-white univer-px-3 univer-py-2 univer-text-base
univer-text-gray-900 univer-shadow-md
dark:!univer-bg-gray-700 dark:!univer-text-white
`
: `
univer-box-border univer-grid univer-min-w-52 univer-max-w-full univer-gap-1 univer-overflow-y-auto
univer-overscroll-contain univer-rounded-md univer-bg-white univer-px-2 univer-py-1 univer-text-sm
univer-text-gray-900 univer-shadow-md
dark:!univer-bg-gray-700 dark:!univer-text-white
`;
}
function getContextMenuGroupClassName(sizeVariant: ContextMenuSizeVariant) {
return sizeVariant === 'paragraph-t' ? 'univer-grid univer-gap-2 univer-py-2' : 'univer-grid univer-gap-1 univer-py-1';
}
function isParagraphTHeaderQuickGroup(menuSchema: IMenuSchema, sizeVariant: ContextMenuSizeVariant) {
return sizeVariant === 'paragraph-t'
&& menuSchema.quickLayout === 'icon'
&& CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS.has(menuSchema.key);
}
function shouldClusterParagraphTHeaderQuickGroups(
currentSchema: IMenuSchema,
nextSchema: IMenuSchema | undefined,
sizeVariant: ContextMenuSizeVariant
) {
return isParagraphTHeaderQuickGroup(currentSchema, sizeVariant)
&& !!nextSchema
&& isParagraphTHeaderQuickGroup(nextSchema, sizeVariant);
}
function getContextMenuQuickGroupClassName(
menuSchema: IMenuSchema,
visibleSchemas: IMenuSchema[],
index: number,
sizeVariant: ContextMenuSizeVariant
) {
if (sizeVariant !== 'paragraph-t' || !CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS.has(menuSchema.key)) {
return getContextMenuGroupClassName(sizeVariant);
}
const previousSchema = index > 0 ? visibleSchemas[index - 1] : null;
const nextSchema = index < visibleSchemas.length - 1 ? visibleSchemas[index + 1] : null;
const connectedToPrevious = !!previousSchema?.quickLayout && CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS.has(previousSchema.key);
const connectedToNext = !!nextSchema?.quickLayout && CONTEXT_MENU_HEADER_QUICK_GROUP_KEYS.has(nextSchema.key);
if (connectedToPrevious && connectedToNext) {
return 'univer-grid univer-gap-2 univer-pt-1 univer-pb-1';
}
if (connectedToPrevious) {
return 'univer-grid univer-gap-2 univer-pt-1 univer-pb-2';
}
if (connectedToNext) {
return 'univer-grid univer-gap-2 univer-pt-2 univer-pb-1';
}
return getContextMenuGroupClassName(sizeVariant);
}
function getContextMenuQuickGroupClusterClassName(sizeVariant: ContextMenuSizeVariant) {
return sizeVariant === 'paragraph-t' ? 'univer-grid univer-gap-0 univer-py-2' : getContextMenuGroupClassName(sizeVariant);
}
export function getContextMenuSchemaRenderGroups(
visibleSchemas: IMenuSchema[],
sizeVariant: ContextMenuSizeVariant
): IContextMenuSchemaRenderGroup[] {
const renderGroups: IContextMenuSchemaRenderGroup[] = [];
for (let index = 0; index < visibleSchemas.length; index++) {
const menuSchema = visibleSchemas[index];
const nextSchema = visibleSchemas[index + 1];
if (shouldClusterParagraphTHeaderQuickGroups(menuSchema, nextSchema, sizeVariant)) {
renderGroups.push({
startIndex: index,
endIndex: index + 1,
menuSchemas: [menuSchema, nextSchema!],
});
index += 1;
continue;
}
renderGroups.push({
startIndex: index,
endIndex: index,
menuSchemas: [menuSchema],
});
}
return renderGroups;
}
function getContextMenuHeaderClassName(sizeVariant: ContextMenuSizeVariant) {
return sizeVariant === 'paragraph-t'
? `
univer-px-3 univer-text-sm univer-font-semibold univer-text-gray-600
dark:!univer-text-gray-300
`
: `
univer-px-2 univer-text-xs univer-font-semibold univer-text-gray-600
dark:!univer-text-gray-300
`;
}
function getContextMenuHeaderRowClassName(sizeVariant: ContextMenuSizeVariant) {
return sizeVariant === 'paragraph-t'
? 'univer-flex univer-items-center univer-justify-between univer-gap-2'
: 'univer-flex univer-items-center univer-justify-between univer-gap-1.5';
}
function getContextMenuSubmenuPanelClassName(sizeVariant: ContextMenuSizeVariant) {
return sizeVariant === 'paragraph-t'
? `
univer-overflow-y-auto univer-overscroll-contain univer-rounded-md univer-border
univer-border-solid univer-border-gray-200 univer-bg-white univer-p-2
univer-shadow-md
dark:!univer-border-gray-600 dark:!univer-bg-gray-700
`
: `
univer-overflow-y-auto univer-overscroll-contain univer-rounded-md univer-border
univer-border-solid univer-border-gray-200 univer-bg-white univer-p-1.5
univer-shadow-md
dark:!univer-border-gray-600 dark:!univer-bg-gray-700
`;
}
export function ContextMenuPanel(props: IContextMenuPanelProps) {
const { menuType, menuSessionVersion = 0, className, activeItemIds, hiddenItemIds, onOptionSelect } = props;
const { menuType, menuSessionVersion = 0, className, activeItemIds, hiddenItemIds, sizeVariant = 'default', onOptionSelect } = props;
const menuManagerService = useDependency(IMenuManagerService);
const layoutService = useDependency(ILayoutService);
const [menuElement, setMenuElement] = useState<HTMLDivElement | null>(null);
@@ -162,12 +351,7 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
<div
ref={setMenuElement}
className={clsx(
`
univer-box-border univer-grid univer-min-w-52 univer-max-w-full univer-gap-1 univer-overflow-y-auto
univer-overscroll-contain univer-rounded-md univer-bg-white univer-px-2 univer-py-1 univer-text-sm
univer-text-gray-900 univer-shadow-md
dark:!univer-bg-gray-700 dark:!univer-text-white
`,
getContextMenuPanelClassName(sizeVariant),
borderClassName,
scrollbarClassName,
className
@@ -183,6 +367,7 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
submenuPortalContainer={submenuPortalContainer}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
sizeVariant={sizeVariant}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
/>
@@ -191,9 +376,10 @@ export function ContextMenuPanel(props: IContextMenuPanelProps) {
}
function ContextMenuMenu(props: IContextMenuMenuProps) {
const { menuSchemas, menuSessionVersion, submenuPortalContainer, activeItemIds, hiddenItemIds, onOptionSelect, maxMenuHeight } = props;
const { menuSchemas, menuSessionVersion, submenuPortalContainer, activeItemIds, hiddenItemIds, sizeVariant, onOptionSelect, maxMenuHeight } = props;
const localeService = useDependency(LocaleService);
const hiddenGroupStates = useContextGroupHiddenStates(menuSchemas);
const [activeSubmenuKey, setActiveSubmenuKey] = useState<string | null>(null);
const visibleSchemas = useMemo(() => {
return menuSchemas.filter((item) => {
@@ -208,11 +394,78 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
return !hiddenGroupStates[item.key];
});
}, [hiddenGroupStates, menuSchemas]);
const renderGroups = useMemo(
() => getContextMenuSchemaRenderGroups(visibleSchemas, sizeVariant),
[sizeVariant, visibleSchemas]
);
const renderQuickLayoutGroup = (
menuSchema: IMenuSchema,
index: number,
renderAsClusterChild = false,
hasSeparator = false
) => {
const titleNode = renderMenuSchemaHeader(menuSchema);
return (
<div
key={menuSchema.key}
className={clsx(
renderAsClusterChild
? 'univer-grid'
: getContextMenuQuickGroupClassName(menuSchema, visibleSchemas, index, sizeVariant),
hasSeparator && borderBottomClassName
)}
>
{titleNode}
{menuSchema.quickLayout === 'tile'
? (
<UIQuickTileMenuGroup
item={menuSchema}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
onOptionSelect={onOptionSelect}
/>
)
: (
<UITinyMenuGroup
item={menuSchema}
columns={getContextMenuQuickGroupColumns(menuSchema)}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
sizeVariant={sizeVariant}
layoutVariant={menuSchema.quickLayoutVariant}
onOptionSelect={onOptionSelect}
/>
)}
</div>
);
};
return (
<>
{visibleSchemas.map((menuSchema, index) => {
const hasSeparator = index !== visibleSchemas.length - 1;
{renderGroups.map(({ menuSchemas: groupedSchemas, startIndex, endIndex }) => {
const menuSchema = groupedSchemas[0];
const hasSeparator = shouldShowContextMenuGroupSeparator(visibleSchemas, endIndex);
const titleNode = renderMenuSchemaHeader(menuSchema);
if (groupedSchemas.length > 1) {
return (
<div
key={groupedSchemas.map((schema) => schema.key).join('-')}
className={clsx(
getContextMenuQuickGroupClusterClassName(sizeVariant),
hasSeparator && borderBottomClassName
)}
>
{groupedSchemas.map((groupedMenuSchema, groupedIndex) => renderQuickLayoutGroup(
groupedMenuSchema,
startIndex + groupedIndex,
true
))}
</div>
);
}
if (menuSchema.item) {
return (
@@ -222,9 +475,12 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
menuItem={menuSchema.item as IDisplayMenuItem<IMenuItem>}
menuSessionVersion={menuSessionVersion}
submenuPortalContainer={submenuPortalContainer}
activeSubmenuKey={activeSubmenuKey}
setActiveSubmenuKey={setActiveSubmenuKey}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
hiddenItemIds={hiddenItemIds}
sizeVariant={sizeVariant}
/>
);
}
@@ -234,33 +490,7 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
}
if (menuSchema.quickLayout) {
return (
<div
key={menuSchema.key}
className={clsx(
'univer-py-1',
hasSeparator && borderBottomClassName
)}
>
{menuSchema.quickLayout === 'tile'
? (
<UIQuickTileMenuGroup
item={menuSchema}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
onOptionSelect={onOptionSelect}
/>
)
: (
<UITinyMenuGroup
item={menuSchema}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
onOptionSelect={onOptionSelect}
/>
)}
</div>
);
return renderQuickLayoutGroup(menuSchema, startIndex, false, hasSeparator);
}
if (menuSchema.tiny) {
@@ -268,7 +498,9 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
<div
key={menuSchema.key}
className={clsx(
'univer-flex univer-items-center univer-gap-1 univer-py-1',
sizeVariant === 'paragraph-t'
? 'univer-flex univer-items-center univer-gap-2 univer-py-2'
: 'univer-flex univer-items-center univer-gap-1 univer-py-1',
hasSeparator && borderBottomClassName
)}
>
@@ -280,11 +512,14 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
menuItem={childSchema.item as IDisplayMenuItem<IMenuItem>}
menuSessionVersion={menuSessionVersion}
submenuPortalContainer={submenuPortalContainer}
activeSubmenuKey={activeSubmenuKey}
setActiveSubmenuKey={setActiveSubmenuKey}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
compact
sizeVariant={sizeVariant}
/>
)
))}
@@ -296,20 +531,11 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
<div
key={menuSchema.key}
className={clsx(
'univer-grid univer-gap-1 univer-py-1',
getContextMenuGroupClassName(sizeVariant),
hasSeparator && borderBottomClassName
)}
>
{menuSchema.title && (
<strong
className={`
univer-px-2 univer-text-xs univer-font-semibold univer-text-gray-600
dark:!univer-text-gray-300
`}
>
{localeService.t(menuSchema.title)}
</strong>
)}
{titleNode}
{menuSchema.children.map((childSchema) => (
childSchema.item && (
<ContextMenuMenuItem
@@ -318,10 +544,13 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
menuItem={childSchema.item as IDisplayMenuItem<IMenuItem>}
menuSessionVersion={menuSessionVersion}
submenuPortalContainer={submenuPortalContainer}
activeSubmenuKey={activeSubmenuKey}
setActiveSubmenuKey={setActiveSubmenuKey}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
sizeVariant={sizeVariant}
/>
)
))}
@@ -330,10 +559,63 @@ function ContextMenuMenu(props: IContextMenuMenuProps) {
})}
</>
);
function renderMenuSchemaHeader(menuSchema: IMenuSchema) {
if (!menuSchema.title) {
return null;
}
const titleContent = (
<strong
className={getContextMenuHeaderClassName(sizeVariant)}
>
{localeService.t(menuSchema.title)}
</strong>
);
if (!menuSchema.headerActionItem) {
return titleContent;
}
return (
<div className={getContextMenuHeaderRowClassName(sizeVariant)}>
{titleContent}
<ContextMenuMenuItem
menuKey={`${menuSchema.key}-header-action`}
menuItem={menuSchema.headerActionItem as IDisplayMenuItem<IMenuItem>}
menuSessionVersion={menuSessionVersion}
submenuPortalContainer={submenuPortalContainer}
activeSubmenuKey={activeSubmenuKey}
setActiveSubmenuKey={setActiveSubmenuKey}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
compact
headerAction
sizeVariant={sizeVariant}
onOptionSelect={onOptionSelect}
maxMenuHeight={maxMenuHeight}
/>
</div>
);
}
}
function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
const { menuKey, menuItem, menuSessionVersion, submenuPortalContainer, activeItemIds, hiddenItemIds = [], compact = false, onOptionSelect, maxMenuHeight } = props;
const {
menuKey,
menuItem,
menuSessionVersion,
submenuPortalContainer,
maxMenuHeight,
activeSubmenuKey,
setActiveSubmenuKey,
activeItemIds,
hiddenItemIds = [],
compact = false,
headerAction = false,
sizeVariant,
onOptionSelect,
} = props;
const localeService = useDependency(LocaleService);
const direction = useObservable(localeService.direction$);
const menuManagerService = useDependency(IMenuManagerService);
@@ -346,7 +628,6 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
isObservable(selectorItem.selections) ? selectorItem.selections : undefined
);
const [inputValue, setInputValue] = useState(value);
const [submenuVisible, setSubmenuVisible] = useState(false);
const [submenuPosition, setSubmenuPosition] = useState<{
left: number;
top: number;
@@ -383,6 +664,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
const hasSelectionSubmenu = selections.length > 0;
const hasSubItemSubmenu = subMenuItems.length > 0;
const hasSubmenu = hasSelectionSubmenu || hasSubItemSubmenu;
const submenuVisible = hasSubmenu && activeSubmenuKey === menuKey;
const selectionsCommandId = selectorItem.selectionsCommandId;
const clearSubmenuCloseTimer = useCallback(() => {
@@ -398,9 +680,9 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
clearSubmenuCloseTimer();
submenuCloseTimerRef.current = setTimeout(() => {
submenuCloseTimerRef.current = null;
setSubmenuVisible(false);
setActiveSubmenuKey((currentKey) => (currentKey === menuKey ? null : currentKey));
}, CONTEXT_MENU_SUBMENU_CLOSE_DELAY);
}, [clearSubmenuCloseTimer]);
}, [clearSubmenuCloseTimer, menuKey, setActiveSubmenuKey]);
useEffect(() => {
setInputValue(value);
@@ -466,22 +748,47 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
const onSubmenuOptionSelect = (option: IValueOption) => {
onOptionSelect?.(option);
clearSubmenuCloseTimer();
setSubmenuVisible(false);
setActiveSubmenuKey((currentKey) => (currentKey === menuKey ? null : currentKey));
};
const itemClassName = clsx(
compact
? `
univer-relative univer-flex univer-size-8 univer-items-center univer-justify-center univer-rounded-md
univer-border-none univer-bg-transparent univer-p-0 univer-text-left univer-text-sm
dark:!univer-text-white
`
: `
univer-relative univer-flex univer-min-h-8 univer-w-full univer-items-center univer-justify-between
univer-gap-3 univer-rounded-md univer-border-none univer-bg-transparent univer-px-2 univer-text-left
univer-text-sm
dark:!univer-text-white
`,
? (
sizeVariant === 'paragraph-t'
? `
univer-relative univer-flex
${headerAction
? 'univer-size-8 univer-rounded-md'
: 'univer-size-10 univer-rounded-lg'}
univer-items-center univer-justify-center univer-border-none univer-bg-transparent univer-p-0
univer-text-left univer-text-base
dark:!univer-text-white
`
: `
univer-relative univer-flex
${headerAction
? 'univer-size-7 univer-rounded-sm'
: 'univer-size-8 univer-rounded-md'}
univer-items-center univer-justify-center univer-border-none univer-bg-transparent univer-p-0
univer-text-left univer-text-sm
dark:!univer-text-white
`
)
: (
sizeVariant === 'paragraph-t'
? `
univer-relative univer-flex univer-min-h-10 univer-w-full univer-items-center
univer-justify-between univer-gap-4 univer-rounded-lg univer-border-none univer-bg-transparent
univer-px-3 univer-text-left univer-text-base
dark:!univer-text-white
`
: `
univer-relative univer-flex univer-min-h-8 univer-w-full univer-items-center
univer-justify-between univer-gap-3 univer-rounded-md univer-border-none univer-bg-transparent
univer-px-2 univer-text-left univer-text-sm
dark:!univer-text-white
`
),
disabled
? 'univer-cursor-not-allowed univer-opacity-60'
: `
@@ -496,7 +803,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
);
const contentNode = (
<span className={contentClassName}>
<span className={getContextMenuContentClassName(sizeVariant)}>
<CustomLabel
value={inputValue}
title={compact ? undefined : menuItem.title}
@@ -523,7 +830,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
clearSubmenuCloseTimer();
if (hasSubmenu && !disabled) {
setSubmenuPositionReady(false);
setSubmenuVisible(true);
setActiveSubmenuKey(menuKey);
}
}}
onMouseLeave={(event) => {
@@ -546,7 +853,8 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
{hasSubmenu && (
<MoreIcon
className={`
univer-size-3.5 univer-text-gray-400
${sizeVariant === 'paragraph-t' ? 'univer-size-4' : 'univer-size-3.5'}
univer-text-gray-400
dark:!univer-text-gray-200
`}
/>
@@ -558,7 +866,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
type="button"
className={interactiveItemClassName}
disabled={disabled}
title={compact && typeof menuItem.tooltip === 'string' ? localeService.t(menuItem.tooltip) : undefined}
title={typeof menuItem.tooltip === 'string' ? localeService.t(menuItem.tooltip) : undefined}
onClick={() => {
clearSubmenuCloseTimer();
if (hasSubmenu) {
@@ -575,7 +883,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
}
setSubmenuPositionReady(false);
setSubmenuVisible(true);
setActiveSubmenuKey(menuKey);
return;
}
@@ -597,7 +905,8 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
{hasSubmenu && !compact && (
<MoreIcon
className={`
univer-size-3.5 univer-text-gray-400
${sizeVariant === 'paragraph-t' ? 'univer-size-4' : 'univer-size-3.5'}
univer-text-gray-400
dark:!univer-text-gray-200
`}
/>
@@ -636,12 +945,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
>
<div
className={clsx(
`
univer-overflow-y-auto univer-overscroll-contain univer-rounded-md univer-border
univer-border-solid univer-border-gray-200 univer-bg-white univer-p-2
univer-shadow-md
dark:!univer-border-gray-600 dark:!univer-bg-gray-700
`,
getContextMenuSubmenuPanelClassName(sizeVariant),
scrollbarClassName
)}
style={{
@@ -649,19 +953,32 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
}}
>
{hasSelectionSubmenu && (
<div className="univer-grid univer-gap-1">
<div
className={sizeVariant === 'paragraph-t'
? 'univer-grid univer-gap-2'
: 'univer-grid univer-gap-1'}
>
{selections.map((option, index) => {
const optionKey = `${menuItem.id}-${option.label ?? option.id}-${index}`;
const optionSelected = typeof inputValue !== 'undefined' && String(inputValue) === String(option.value);
const optionSelectable = !isNonSelectableLabel(option.label);
const optionHoverable = !isNonHoverableLabel(option.label);
const optionClassName = clsx(
`
univer-relative univer-box-border univer-flex univer-min-h-8
univer-w-full univer-items-center univer-rounded-md univer-border-none
univer-bg-transparent univer-px-2 univer-text-left univer-text-sm
dark:!univer-text-white
`,
sizeVariant === 'paragraph-t'
? `
univer-relative univer-box-border univer-flex univer-min-h-10
univer-w-full univer-items-center univer-rounded-lg
univer-border-none univer-bg-transparent univer-px-3
univer-text-left univer-text-base
dark:!univer-text-white
`
: `
univer-relative univer-box-border univer-flex univer-min-h-8
univer-w-full univer-items-center univer-rounded-md
univer-border-none univer-bg-transparent univer-px-2
univer-text-left univer-text-sm
dark:!univer-text-white
`,
option.disabled
? 'univer-cursor-not-allowed univer-opacity-60'
: optionHoverable && `
@@ -674,14 +991,16 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
<>
{optionSelectable && optionSelected && (
<CheckMarkIcon
className="
univer-absolute univer-left-0 univer-size-4
univer-text-primary-600
"
className={clsx(
'univer-absolute univer-left-0 univer-text-primary-600',
sizeVariant === 'paragraph-t'
? 'univer-size-5'
: 'univer-size-4'
)}
/>
)}
<span
className={clsx(contentClassName, optionSelectable && optionSelected && `
className={clsx(getContextMenuContentClassName(sizeVariant), optionSelectable && optionSelected && `
univer-pl-4
`)}
>
@@ -744,6 +1063,7 @@ function ContextMenuMenuItem(props: IContextMenuMenuItemProps) {
submenuPortalContainer={submenuPortalContainer}
activeItemIds={activeItemIds}
hiddenItemIds={hiddenItemIds}
sizeVariant={sizeVariant}
onOptionSelect={onSubmenuOptionSelect}
maxMenuHeight={maxMenuHeight}
/>
@@ -0,0 +1,452 @@
/**
* 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 { act, fireEvent, render, screen } from '@testing-library/react';
import { LocaleService } from '@univerjs/core';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { ILayoutService } from '../../../../services/layout/layout.service';
import { MenuItemType } from '../../../../services/menu/menu';
import { IMenuManagerService } from '../../../../services/menu/menu-manager.service';
import {
CONTEXT_MENU_SUBMENU_CLOSE_DELAY,
CONTEXT_MENU_SUBMENU_PORTAL_ATTR,
ContextMenuPanel,
getContextMenuQuickGroupColumns,
getContextMenuSchemaRenderGroups,
shouldShowContextMenuGroupSeparator,
} from '../ContextMenuPanel';
const dependencyMap = new Map();
const tinyMenuGroupSpy = vi.fn();
vi.mock('../../../../utils/di', async () => {
const ReactModule = await import('react');
return {
useDependency(token: unknown) {
return dependencyMap.get(token);
},
useObservable<T>(observable: any, defaultValue?: T) {
const [value, setValue] = ReactModule.useState(defaultValue);
ReactModule.useEffect(() => {
if (!observable) {
return;
}
const source = typeof observable === 'function' ? observable() : observable;
const sub = source.subscribe?.((nextValue: T) => setValue(nextValue));
return () => sub?.unsubscribe?.();
}, [observable]);
return value;
},
};
});
vi.mock('../../../../components/menu/desktop/TinyMenuGroup', () => ({
resolveMenuItemActiveState: () => false,
UITinyMenuGroup: (props: unknown) => {
tinyMenuGroupSpy(props);
return <div data-testid="tiny-menu-group" />;
},
UIQuickTileMenuGroup: () => <div data-testid="quick-tile-menu-group" />,
}));
vi.mock('../../../../components/custom-label/CustomLabel', () => ({
CustomLabel: () => <span data-testid="custom-label" />,
}));
describe('ContextMenuPanel', () => {
it('applies the enlarged paragraph T size variant without affecting the shared defaults', () => {
dependencyMap.clear();
tinyMenuGroupSpy.mockClear();
dependencyMap.set(IMenuManagerService, {
menuChanged$: new BehaviorSubject<void>(undefined),
getMenuByPositionKey: vi.fn(() => [{
key: 'quickTop',
order: 0,
title: 'docs-ui.paragraphMenu.align',
quickLayout: 'icon',
children: [{
key: 'quickItem',
order: 0,
item: {
id: 'quick-item',
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: 'quick-layout-menu',
sizeVariant: 'paragraph-t',
}));
const className = (container.firstChild as HTMLDivElement | null)?.className ?? '';
expect(className).toContain('univer-min-w-64');
expect(className).toContain('univer-text-base');
expect(className).toContain('univer-px-3');
expect(className).toContain('univer-py-2');
expect(tinyMenuGroupSpy.mock.calls[0][0]).toEqual(expect.objectContaining({
columns: 6,
sizeVariant: 'paragraph-t',
}));
});
it('does not render a separator between consecutive header quick rows', () => {
expect(shouldShowContextMenuGroupSeparator([
{ key: 'quickTop', order: 0, quickLayout: 'icon' },
{ key: 'quickBottom', order: 1, quickLayout: 'icon' },
{ key: 'layout', order: 2 },
] as never, 0)).toBe(false);
expect(shouldShowContextMenuGroupSeparator([
{ key: 'quickTop', order: 0, quickLayout: 'icon' },
{ key: 'quickBottom', order: 1, quickLayout: 'icon' },
{ key: 'layout', order: 2 },
] as never, 1)).toBe(true);
});
it('clusters consecutive paragraph T header quick rows into one shared container', () => {
dependencyMap.clear();
tinyMenuGroupSpy.mockClear();
dependencyMap.set(IMenuManagerService, {
menuChanged$: new BehaviorSubject<void>(undefined),
getMenuByPositionKey: vi.fn(() => [
{
key: 'quickTop',
order: 0,
quickLayout: 'icon',
children: [{
key: 'quickItemTop',
order: 0,
item: {
id: 'quick-item-top',
type: MenuItemType.BUTTON,
},
}],
},
{
key: 'quickBottom',
order: 1,
quickLayout: 'icon',
children: [{
key: 'quickItemBottom',
order: 0,
item: {
id: 'quick-item-bottom',
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: 'quick-layout-menu',
sizeVariant: 'paragraph-t',
}));
const tinyGroups = screen.getAllByTestId('tiny-menu-group');
const sharedCluster = container.querySelector('.univer-gap-0') as HTMLDivElement | null;
expect(sharedCluster).not.toBeNull();
expect(sharedCluster?.className ?? '').toContain('univer-py-2');
expect(sharedCluster?.querySelectorAll('[data-testid="tiny-menu-group"]').length).toBe(2);
expect(container.querySelectorAll('.univer-gap-0 [data-testid="tiny-menu-group"]').length).toBe(2);
});
it('groups consecutive paragraph T header quick rows before rendering', () => {
expect(getContextMenuSchemaRenderGroups([
{ key: 'quickTop', order: 0, quickLayout: 'icon' },
{ key: 'quickBottom', order: 1, quickLayout: 'icon' },
{ key: 'align', order: 2 },
] as never, 'paragraph-t')).toEqual([
{
startIndex: 0,
endIndex: 1,
menuSchemas: [
{ key: 'quickTop', order: 0, quickLayout: 'icon' },
{ key: 'quickBottom', order: 1, quickLayout: 'icon' },
],
},
{
startIndex: 2,
endIndex: 2,
menuSchemas: [
{ key: 'align', order: 2 },
],
},
]);
});
it('uses a fixed six-column layout for header quick icon groups only', () => {
expect(getContextMenuQuickGroupColumns({
key: 'quickTop',
order: 0,
quickLayout: 'icon',
} as never)).toBe(6);
expect(getContextMenuQuickGroupColumns({
key: 'quickBottom',
order: 1,
quickLayout: 'icon',
} as never)).toBe(6);
expect(getContextMenuQuickGroupColumns({
key: 'align',
order: 0,
quickLayout: 'icon',
} as never)).toBeUndefined();
});
it('passes compact quick layout metadata through to tiny menu groups', () => {
dependencyMap.clear();
tinyMenuGroupSpy.mockClear();
dependencyMap.set(IMenuManagerService, {
menuChanged$: new BehaviorSubject<void>(undefined),
getMenuByPositionKey: vi.fn(() => [{
key: 'colors',
order: 0,
quickLayout: 'icon',
quickColumns: 8,
quickLayoutVariant: 'compact',
children: [{
key: 'quickItem',
order: 0,
item: {
id: 'quick-item',
type: MenuItemType.BUTTON,
},
}],
}]),
});
dependencyMap.set(ILayoutService, {
rootContainerElement: document.body,
});
dependencyMap.set(LocaleService, {
t: (key: string) => key,
direction$: new BehaviorSubject<'ltr'>('ltr'),
});
render(React.createElement(ContextMenuPanel as never, {
menuType: 'quick-layout-menu',
sizeVariant: 'paragraph-t',
}));
expect(tinyMenuGroupSpy.mock.calls[0][0]).toEqual(expect.objectContaining({
columns: 8,
layoutVariant: 'compact',
}));
});
it('renders a right-aligned header action for titled context menu groups', () => {
dependencyMap.clear();
tinyMenuGroupSpy.mockClear();
dependencyMap.set(IMenuManagerService, {
menuChanged$: new BehaviorSubject<void>(undefined),
getMenuByPositionKey: vi.fn(() => [{
key: 'colors',
order: 0,
title: 'docs-ui.toolbar.textColor.main',
headerActionItem: {
id: 'header-action',
type: MenuItemType.BUTTON_SELECTOR,
icon: 'HeaderTextColorIcon',
tooltip: 'header-action',
selections: [],
},
children: [{
key: 'quickItem',
order: 0,
item: {
id: 'quick-item',
type: MenuItemType.BUTTON,
},
}],
}]),
});
dependencyMap.set(ILayoutService, {
rootContainerElement: document.body,
});
dependencyMap.set(LocaleService, {
t: (key: string) => key,
direction$: new BehaviorSubject<'ltr'>('ltr'),
});
render(React.createElement(ContextMenuPanel as never, {
menuType: 'quick-layout-menu',
sizeVariant: 'paragraph-t',
}));
expect(document.querySelector('button[title="header-action"]')).not.toBeNull();
});
it('renders quick layout group titles before icon rows', () => {
dependencyMap.clear();
dependencyMap.set(IMenuManagerService, {
menuChanged$: new BehaviorSubject<void>(undefined),
getMenuByPositionKey: vi.fn(() => [{
key: 'quickGroup',
order: 0,
title: 'docs-ui.paragraphMenu.align',
quickLayout: 'icon',
children: [{
key: 'quickItem',
order: 0,
item: {
id: 'quick-item',
type: MenuItemType.BUTTON,
},
}],
}]),
});
dependencyMap.set(ILayoutService, {
rootContainerElement: document.body,
});
dependencyMap.set(LocaleService, {
t: (key: string) => key,
direction$: new BehaviorSubject<'ltr'>('ltr'),
});
render(<ContextMenuPanel menuType="quick-layout-menu" />);
expect(screen.getAllByText('docs-ui.paragraphMenu.align').length).toBeGreaterThan(0);
expect(screen.getAllByTestId('tiny-menu-group').length).toBeGreaterThan(0);
});
it('keeps the newly hovered submenu open when switching quickly between sibling submenu items', () => {
vi.useFakeTimers();
dependencyMap.clear();
dependencyMap.set(IMenuManagerService, {
menuChanged$: new BehaviorSubject<void>(undefined),
getMenuByPositionKey: vi.fn((key: string) => {
if (key === 'submenu-root') {
return [
{
key: 'indent',
order: 0,
item: {
id: 'indent',
type: MenuItemType.SUBITEMS,
title: 'indent',
tooltip: 'indent',
},
},
{
key: 'colors',
order: 1,
item: {
id: 'colors',
type: MenuItemType.SUBITEMS,
title: 'colors',
tooltip: 'colors',
},
},
];
}
if (key === 'indent') {
return [{
key: 'indent-option',
order: 0,
item: {
id: 'indent-option',
type: MenuItemType.BUTTON,
title: 'indent option',
},
}];
}
if (key === 'colors') {
return [{
key: 'colors-option',
order: 0,
item: {
id: 'colors-option',
type: MenuItemType.BUTTON,
title: 'colors option',
},
}];
}
return [];
}),
});
dependencyMap.set(ILayoutService, {
rootContainerElement: document.body,
});
dependencyMap.set(LocaleService, {
t: (key: string) => key,
direction$: new BehaviorSubject<'ltr'>('ltr'),
});
render(<ContextMenuPanel menuType="submenu-root" />);
const indentButton = document.querySelector('button[title="indent"]') as HTMLButtonElement | null;
const colorsButton = document.querySelector('button[title="colors"]') as HTMLButtonElement | null;
expect(indentButton).not.toBeNull();
expect(colorsButton).not.toBeNull();
const indentWrapper = indentButton?.parentElement as HTMLDivElement | null;
const colorsWrapper = colorsButton?.parentElement as HTMLDivElement | null;
expect(indentWrapper).not.toBeNull();
expect(colorsWrapper).not.toBeNull();
act(() => {
fireEvent.mouseEnter(indentWrapper!);
});
expect(document.querySelectorAll(`[${CONTEXT_MENU_SUBMENU_PORTAL_ATTR}="true"]`)).toHaveLength(1);
act(() => {
fireEvent.mouseLeave(indentWrapper!, { relatedTarget: colorsWrapper });
fireEvent.mouseEnter(colorsWrapper!);
});
expect(document.querySelectorAll(`[${CONTEXT_MENU_SUBMENU_PORTAL_ATTR}="true"]`)).toHaveLength(1);
act(() => {
vi.advanceTimersByTime(CONTEXT_MENU_SUBMENU_CLOSE_DELAY + 10);
});
expect(document.querySelectorAll(`[${CONTEXT_MENU_SUBMENU_PORTAL_ATTR}="true"]`)).toHaveLength(1);
vi.useRealTimers();
});
});
@@ -114,7 +114,7 @@ export function DropdownWrapper(props: Omit<Partial<IDropdownProps>, 'overlay'>
);
}
function Label({ icon, value, option, onOptionSelect }: {
export function DropdownMenuLabel({ icon, value, option, onOptionSelect }: {
icon?: IMenuItem['icon'];
value?: string | number;
option: IValueOption;
@@ -125,26 +125,27 @@ function Label({ icon, value, option, onOptionSelect }: {
};
const hasCheckMark = typeof option.label === 'string' || (typeof option.label === 'object' && option.label?.selectable !== false);
const selected = hasCheckMark && String(value) === String(option.value);
return (
<div
className={clsx('univer-relative univer-flex univer-items-center univer-gap-2', {
'univer-pl-6': hasCheckMark,
})}
>
{hasCheckMark && String(value) === String(option.value) && (
<CheckMarkIcon
className="univer-absolute univer-left-1 univer-top-0.5 univer-text-primary-600"
<div className="univer-flex univer-w-full univer-items-center univer-justify-between univer-gap-2">
<div className="univer-flex univer-min-w-0 univer-items-center univer-gap-2">
<CustomLabel
className="univer-text-sm"
icon={icon}
value$={option.value$}
value={option.value}
label={option.label}
onChange={onChange}
/>
</div>
{hasCheckMark && (
<span className="univer-ml-auto univer-flex univer-w-4 univer-flex-shrink-0 univer-justify-end">
{selected && (
<CheckMarkIcon className="univer-text-primary-600" />
)}
</span>
)}
<CustomLabel
className="univer-text-sm"
icon={icon}
value$={option.value$}
value={option.value}
label={option.label}
onChange={onChange}
/>
</div>
);
}
@@ -244,7 +245,7 @@ export function DropdownMenuWrapper({
<DropdownWrapper
disabled={disabled}
overlay={options.map((option, index) => (
<Label
<DropdownMenuLabel
key={index}
value={value}
option={option}
@@ -265,7 +266,7 @@ export function DropdownMenuWrapper({
'focus:univer-bg-white': typeof option.label !== 'string' && option.label?.hoverable === false,
}),
children: (
<Label
<DropdownMenuLabel
icon={option.icon}
value={value}
option={option}
@@ -300,7 +301,7 @@ export function DropdownMenuWrapper({
items.push({
type: 'item',
children: (
<Label
<DropdownMenuLabel
icon={icon}
value={value}
option={{
@@ -345,7 +346,7 @@ export function DropdownMenuWrapper({
items.push({
type: 'item',
children: (
<Label
<DropdownMenuLabel
icon={icon}
value={value}
option={{
@@ -0,0 +1,73 @@
/**
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { DropdownMenuLabel } from '../TooltipButtonWrapper';
vi.mock('../../../../components/custom-label/CustomLabel', () => ({
CustomLabel: (props: { label?: unknown }) => React.createElement(
'span',
{
'data-testid': 'custom-label',
},
typeof props.label === 'string' ? props.label : 'custom-label'
),
}));
describe('DropdownMenuLabel', () => {
it('keeps menu content left-aligned and renders the selected checkmark on the right', () => {
const { container } = render(
<DropdownMenuLabel
value="normal"
option={{
value: 'normal',
label: 'Normal',
}}
/>
);
const root = container.firstElementChild as HTMLElement;
const children = Array.from(root.children) as HTMLElement[];
expect(root.className).toContain('univer-justify-between');
expect(children[0].className).toContain('univer-min-w-0');
expect(children[0].className).toContain('univer-gap-2');
expect(children[1].className).toContain('univer-ml-auto');
expect(children[1].querySelector('svg')).toBeTruthy();
expect(screen.getByTestId('custom-label').textContent).toBe('Normal');
});
it('reserves the right-side checkmark slot for selectable items even when not selected', () => {
const { container } = render(
<DropdownMenuLabel
value="normal"
option={{
value: 'heading-1',
label: 'Heading 1',
}}
/>
);
const root = container.firstElementChild as HTMLElement;
const children = Array.from(root.children) as HTMLElement[];
expect(root.className).toContain('univer-justify-between');
expect(children[1].className).toContain('univer-w-4');
expect(children[1].querySelector('svg')).toBeNull();
});
});