mirror of
https://github.com/dream-num/univer.git
synced 2026-09-24 15:32:18 +08:00
feat(docs): refine paragraph menus and table viewport (#7050)
This commit is contained in:
@@ -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,
|
||||
|
||||
+7
-2
@@ -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();
|
||||
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
+5
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-1
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: 'الوضع',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'حالت',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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à',
|
||||
|
||||
@@ -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: 'モード',
|
||||
|
||||
@@ -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: '모드',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Режим',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ế độ',
|
||||
|
||||
@@ -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: '模式',
|
||||
|
||||
@@ -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: '模式',
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}, {});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user