diff --git a/packages/ui/src/facade/__tests__/f-univer.spec.ts b/packages/ui/src/facade/__tests__/f-univer.spec.ts index 23cac5550c..d1c670e125 100644 --- a/packages/ui/src/facade/__tests__/f-univer.spec.ts +++ b/packages/ui/src/facade/__tests__/f-univer.spec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { IMenuSchema } from '@univerjs/ui'; import { CommandType, ICommandService, @@ -33,6 +34,7 @@ import { CopyCommand, DesktopDialogService, DesktopMessageService, + DesktopRibbonService, DesktopSidebarService, FontService, IDialogService, @@ -40,6 +42,7 @@ import { IMenuManagerService, IMessageService, IPlatformService, + IRibbonService, IShortcutService, ISidebarService, IUIPartsService, @@ -48,6 +51,7 @@ import { MenuManagerService, PasteCommand, PlatformService, + RibbonStartGroup, ShortcutService, UI_PLUGIN_CONFIG_KEY, UIPartsService, @@ -153,6 +157,53 @@ describe('ui facade', () => { expect(menuKeys).toContain('second-action'); }); + it('updates ribbon menu order and grid layout while preserving menu actions and earlier overrides', async () => { + const injector = univer.__getInjector(); + injector.add([IRibbonService, { useClass: DesktopRibbonService }]); + const ribbonService = injector.get(IRibbonService); + const actionCalls: string[] = []; + let ribbon: IMenuSchema[] = []; + const subscription = ribbonService.ribbon$.subscribe((value) => { + ribbon = value; + }); + + univerAPI.updateMenuConfig({ + first: { title: 'Renamed', gridLayout: { row: 1, column: 1, width: 80 } }, + }); + univerAPI.createMenu({ + id: 'first', + title: 'First', + action: () => actionCalls.push('clicked'), + order: 0, + gridLayout: { row: 1, column: 1, showLabel: true }, + }).appendTo(RibbonStartGroup.FORMAT); + univerAPI.createSubmenu({ + id: 'second', + title: 'Second', + order: 1, + gridLayout: { row: 2, column: 1 }, + }).addSubmenu(univerAPI.createMenu({ id: 'child', title: 'Child', action: 'child.command' })).appendTo(RibbonStartGroup.FORMAT); + + expect(ribbon[0].children?.[0].children?.map((item) => [item.key, item.gridLayout?.row])).toEqual([ + ['first', 1], + ['second', 2], + ]); + expect(univerAPI.updateMenuConfig({ + first: { order: 2, gridLayout: { row: 2, column: 1 } }, + second: { order: 0, gridLayout: { row: 1, column: 1 } }, + })).toBe(univerAPI); + + const items = ribbon[0].children![0].children!; + expect(items.map((item) => item.key)).toEqual(['second', 'first']); + expect(items[0].gridLayout).toEqual({ row: 1, column: 1 }); + expect(items[1].gridLayout).toEqual({ row: 2, column: 1, showLabel: true, width: 80 }); + expect(items[1].item?.title).toBe('Renamed'); + await injector.get(ICommandService).executeCommand(items[1].item!.commandId!); + expect(actionCalls).toEqual(['clicked']); + + subscription.unsubscribe(); + }); + it('registers UI parts, toggles visibility, and exposes UI enums from the facade', () => { const component = () => null; const disposable = univerAPI.registerUIPart(BuiltInUIPart.CUSTOM_HEADER, component); diff --git a/packages/ui/src/facade/f-menu-builder.ts b/packages/ui/src/facade/f-menu-builder.ts index 44dffd830d..7236b5aa57 100644 --- a/packages/ui/src/facade/f-menu-builder.ts +++ b/packages/ui/src/facade/f-menu-builder.ts @@ -15,7 +15,7 @@ */ import type { IAccessor } from '@univerjs/core'; -import type { IMenuButtonItem, IMenuItem, MenuSchemaType } from '@univerjs/ui'; +import type { IMenuButtonItem, IMenuItem, IRibbonGridLayout, MenuSchemaType } from '@univerjs/ui'; import { CommandType, generateRandomId, ICommandService, Inject, Injector } from '@univerjs/core'; import { FBase } from '@univerjs/core/facade'; import { IMenuManagerService, MenuItemType, MenuManagerPosition, RibbonPosition, RibbonStartGroup } from '@univerjs/ui'; @@ -49,6 +49,8 @@ export interface IFacadeMenuItem { * The order of the menu item in the submenu. */ order?: number; + /** Placement within a ribbon group when ribbonType is 'grid'. */ + gridLayout?: IRibbonGridLayout; } /** @@ -75,6 +77,8 @@ export interface IFacadeSubmenuItem { * The order of the menu item in the submenu. */ order?: number; + /** Placement within a ribbon group when ribbonType is 'grid'. */ + gridLayout?: IRibbonGridLayout; } /** @@ -156,6 +160,7 @@ export class FMenu extends FMenuBase { private _commandToRegister = new Map void>(); private _buildingSchema: { order?: number; + gridLayout?: IRibbonGridLayout; menuItemFactory?: (accessor: IAccessor) => IMenuItem; }; @@ -186,6 +191,10 @@ export class FMenu extends FMenuBase { if (typeof _item.order !== 'undefined') { this._buildingSchema.order = _item.order; } + + if (_item.gridLayout) { + this._buildingSchema.gridLayout = _item.gridLayout; + } } /** @@ -222,6 +231,7 @@ export class FSubmenu extends FMenuBase { private _buildingSchema: { order?: number; + gridLayout?: IRibbonGridLayout; menuItemFactory?: (accessor: IAccessor) => IMenuItem; }; @@ -245,6 +255,10 @@ export class FSubmenu extends FMenuBase { if (typeof _item.order !== 'undefined') { this._buildingSchema.order = _item.order; } + + if (_item.gridLayout) { + this._buildingSchema.gridLayout = _item.gridLayout; + } } /** diff --git a/packages/ui/src/facade/f-univer.ts b/packages/ui/src/facade/f-univer.ts index a77475d9dd..8890e14eb2 100644 --- a/packages/ui/src/facade/f-univer.ts +++ b/packages/ui/src/facade/f-univer.ts @@ -16,13 +16,33 @@ import type { IDisposable } from '@univerjs/core'; import type { IMessageProps } from '@univerjs/design'; -import type { BuiltInUIPart, ComponentType, IComponentOptions, IDialogPartMethodOptions, IFontConfig, ISidebarMethodOptions, RibbonType } from '@univerjs/ui'; +import type { + BuiltInUIPart, + ComponentType, + IComponentOptions, + IDialogPartMethodOptions, + IFontConfig, + ISidebarMethodOptions, + MenuConfig, + RibbonType, +} from '@univerjs/ui'; import type { IFacadeMenuItem, IFacadeSubmenuItem } from './f-menu-builder'; import { IConfigService } from '@univerjs/core'; import { FUniver } from '@univerjs/core/facade'; import { IRenderManagerService } from '@univerjs/engine-render'; - -import { ComponentManager, connectInjector, CopyCommand, IDialogService, IFontService, IMessageService, ISidebarService, IUIPartsService, PasteCommand, UI_PLUGIN_CONFIG_KEY } from '@univerjs/ui'; +import { + ComponentManager, + connectInjector, + CopyCommand, + IDialogService, + IFontService, + IMenuManagerService, + IMessageService, + ISidebarService, + IUIPartsService, + PasteCommand, + UI_PLUGIN_CONFIG_KEY, +} from '@univerjs/ui'; import { FMenu, FSubmenu } from './f-menu-builder'; import { FShortcut } from './f-shortcut'; @@ -225,6 +245,30 @@ export interface IFUniverUIMixin { */ createSubmenu(submenuItem: IFacadeSubmenuItem): FSubmenu; + /** + * Merge menu overrides and refresh the UI immediately. Overrides also apply to menus registered later. + * Use the same configuration as the UI plugin or preset's `menu` option. + * @param {MenuConfig} config Overrides keyed by menu item ID, ribbon tab key, or ribbon group key. + * Unspecified properties keep their current values. Lower `order` values come first among siblings. + * `gridLayout` applies only to the grid ribbon and uses 1-based positions within a two-row group. + * @returns the {@link FUniver} instance for chaining + * @example + * ```ts + * univerAPI.updateMenuConfig({ + * 'ribbon.insert': { order: -1 }, + * 'ribbon.start.format': { order: -1 }, + * 'sheet.command.set-range-bold': { + * order: 0, + * gridLayout: { row: 2, column: 2, showLabel: true, width: 80 }, + * }, + * 'sheet.command.set-range-italic': { + * gridLayout: { row: 2, column: 1 }, + * }, + * }); + * ``` + */ + updateMenuConfig(config: MenuConfig): FUniver; + /** * Open a sidebar. * @param {ISidebarMethodOptions} params the sidebar options @@ -479,6 +523,11 @@ export class FUniverUIMixin extends FUniver implements IFUniverUIMixin { return this._injector.createInstance(FSubmenu, submenuItem); } + override updateMenuConfig(config: MenuConfig): FUniver { + this._injector.get(IMenuManagerService).updateMenuConfig(config); + return this; + } + override openSidebar(params: ISidebarMethodOptions): IDisposable { const sideBarService = this._injector.get(ISidebarService); return sideBarService.open(params); diff --git a/packages/ui/src/services/menu/__tests__/menu-manager.service.spec.ts b/packages/ui/src/services/menu/__tests__/menu-manager.service.spec.ts index 3377837ce6..c8ebdc6257 100644 --- a/packages/ui/src/services/menu/__tests__/menu-manager.service.spec.ts +++ b/packages/ui/src/services/menu/__tests__/menu-manager.service.spec.ts @@ -14,42 +14,43 @@ * limitations under the License. */ -import type { MenuSchemaType } from '../menu-manager.service'; +import type { MenuConfig } from '../menu'; import { ConfigService, IConfigService, Injector } from '@univerjs/core'; + import { describe, expect, it } from 'vitest'; import { isMenuButtonSelectorItem, isMenuSelectorItem, MenuItemType } from '../menu'; import { IMenuManagerService, MenuManagerService } from '../menu-manager.service'; -import { FloatingObjectToolbarPosition } from '../types'; +import { FloatingObjectToolbarPosition, MenuManagerPosition, RibbonPosition, RibbonStartGroup } from '../types'; -function createService(): IMenuManagerService { +function createInjector(): Injector { const injector = new Injector(); injector.add([IConfigService, { useClass: ConfigService }]); injector.add([IMenuManagerService, { useClass: MenuManagerService }]); - return injector.get(IMenuManagerService); + return injector; } describe('MenuManagerService', () => { it('appends command menus and returns them in display order', () => { - const service = createService(); + const service = createInjector().get(IMenuManagerService); service.appendRootMenu({ testPosition: { - second: { order: 2, menuItemFactory: () => ({ id: 'second' }) }, - first: { order: 1, menuItemFactory: () => ({ id: 'first' }) }, + second: { order: 2, menuItemFactory: () => ({ id: 'second', type: MenuItemType.BUTTON }) }, + first: { order: 1, menuItemFactory: () => ({ id: 'first', type: MenuItemType.BUTTON }) }, }, - } as MenuSchemaType); + }); expect(service.getMenuByPositionKey('testPosition').map((item) => item.key)).toEqual(['first', 'second']); expect(service.getFlatMenuByPositionKey('testPosition').map((item) => item.item?.id)).toEqual(['first', 'second']); }); it('merges menu contributions into existing positions and emits a change event', () => { - const service = createService(); + const service = createInjector().get(IMenuManagerService); const changes: void[] = []; const sub = service.menuChanged$.subscribe((value) => changes.push(value)); - service.appendRootMenu({ testMerge: { group: { order: 1 } } } as MenuSchemaType); - service.mergeMenu({ group: { command: { order: 1, menuItemFactory: () => ({ id: 'command' }) } } } as MenuSchemaType); + service.appendRootMenu({ testMerge: { group: { order: 1 } } }); + service.mergeMenu({ group: { command: { order: 1, menuItemFactory: () => ({ id: 'command', type: MenuItemType.BUTTON }) } } }); expect(service.getFlatMenuByPositionKey('testMerge').map((item) => item.key)).toEqual(['group', 'command']); expect(changes.length).toBeGreaterThanOrEqual(2); @@ -57,11 +58,11 @@ describe('MenuManagerService', () => { }); it.each(Object.values(FloatingObjectToolbarPosition))('accepts contributions for %s', (position) => { - const service = createService(); + const service = createInjector().get(IMenuManagerService); service.mergeMenu({ [position]: { - comment: { order: 1, menuItemFactory: () => ({ id: 'comment' }) }, + comment: { order: 1, menuItemFactory: () => ({ id: 'comment', type: MenuItemType.BUTTON }) }, }, }); @@ -69,7 +70,7 @@ describe('MenuManagerService', () => { }); it('returns an empty array for missing menu positions', () => { - const service = createService(); + const service = createInjector().get(IMenuManagerService); expect(service.getMenuByPositionKey('missing-position')).toEqual([]); expect(service.getFlatMenuByPositionKey('missing-position')).toEqual([]); @@ -81,4 +82,48 @@ describe('MenuManagerService', () => { expect(isMenuSelectorItem({ id: 'copy', type: MenuItemType.BUTTON })).toBe(false); expect(isMenuButtonSelectorItem({ id: 'fill-color', type: MenuItemType.BUTTON_SELECTOR })).toBe(true); }); + + it('applies configured tab, group, and item order to menus registered later', () => { + const injector = createInjector(); + const service = injector.get(IMenuManagerService); + injector.get(IConfigService).setConfig('menu', { + [RibbonPosition.INSERT]: { order: -1 }, + [RibbonStartGroup.FORMAT]: { order: -1 }, + font: { order: 2, gridLayout: { row: 2, column: 1, width: 120 } }, + } satisfies MenuConfig); + + service.mergeMenu({ + [RibbonPosition.START]: { + [RibbonStartGroup.HISTORY]: { + undo: { order: 0, menuItemFactory: () => ({ id: 'undo', type: MenuItemType.BUTTON }) }, + }, + [RibbonStartGroup.FORMAT]: { + fontMenu: { + order: 0, + gridLayout: { row: 1, column: 1, columnSpan: 2, showLabel: true, width: 180 }, + menuItemFactory: () => ({ id: 'font', type: MenuItemType.SELECTOR }), + }, + bold: { order: 1, menuItemFactory: () => ({ id: 'bold', type: MenuItemType.BUTTON }) }, + }, + }, + [RibbonPosition.INSERT]: { + custom: { + order: 0, + image: { order: 0, menuItemFactory: () => ({ id: 'image', type: MenuItemType.BUTTON }) }, + }, + }, + }); + + expect(service.getMenuByPositionKey(MenuManagerPosition.RIBBON).map((tab) => tab.key)).toEqual([ + RibbonPosition.INSERT, + RibbonPosition.START, + ]); + expect(service.getMenuByPositionKey(RibbonPosition.START).map((group) => group.key)).toEqual([ + RibbonStartGroup.FORMAT, + RibbonStartGroup.HISTORY, + ]); + const items = service.getMenuByPositionKey(RibbonStartGroup.FORMAT); + expect(items.map((item) => item.key)).toEqual(['bold', 'fontMenu']); + expect(items[1].gridLayout).toEqual({ row: 2, column: 1, columnSpan: 2, showLabel: true, width: 120 }); + }); }); diff --git a/packages/ui/src/services/menu/menu-manager.service.ts b/packages/ui/src/services/menu/menu-manager.service.ts index 8e5934625e..c1ed4546b2 100644 --- a/packages/ui/src/services/menu/menu-manager.service.ts +++ b/packages/ui/src/services/menu/menu-manager.service.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import type { IAccessor, Nullable } from '@univerjs/core'; +import type { IAccessor } from '@univerjs/core'; import type { Observable } from 'rxjs'; -import type { IMenuItem } from './menu'; +import type { IMenuItem, MenuConfig } from './menu'; import { createIdentifier, Disposable, IConfigService, Inject, Injector, merge } from '@univerjs/core'; import { Subject } from 'rxjs'; import { mergeMenuConfigs } from '../../common/menu-merge-configs'; @@ -41,12 +41,19 @@ export type ContextMenuQuickLayoutVariant = 'default' | 'compact'; export type MobileMenuLayout = 'grid' | 'list'; export interface IRibbonGridLayout { + /** 1-based row within the ribbon group's two rows. */ row: number; + /** 1-based column within the ribbon group. */ column: number; + /** Number of rows occupied. Defaults to 1; use 2 for a large button. */ rowSpan?: number; + /** Number of columns occupied. Defaults to 1. */ columnSpan?: number; + /** Whether to display the menu title alongside its icon. */ showLabel?: boolean; + /** Width in pixels. */ width?: number; + /** Icon size in pixels. */ iconSize?: number; } @@ -73,6 +80,8 @@ export interface IMenuManagerService { appendRootMenu(source: MenuSchemaType): void; + updateMenuConfig(config: MenuConfig): void; + getMenuByPositionKey(position: string): IMenuSchema[]; getFlatMenuByPositionKey(position: string): IMenuSchema[]; @@ -302,6 +311,7 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic override dispose(): void { this.menuChanged$.complete(); + super.dispose(); } /** @@ -330,18 +340,23 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic this.menuChanged$.next(); } + updateMenuConfig(config: MenuConfig): void { + this._configService.setConfig('menu', config, { merge: true }); + this.menuChanged$.next(); + } + createScoped(injector: Injector): IMenuManagerService { - const root = this; const createScopedBuilder = () => { - const service = new MenuManagerService(injector, root._configService); - service._menu = root._menu; + const service = new MenuManagerService(injector, this._configService); + service._menu = this._menu; return service; }; return { - menuChanged$: root.menuChanged$, - mergeMenu: (source: MenuSchemaType, target?: MenuSchemaType) => root.mergeMenu(source, target), - appendRootMenu: (source: MenuSchemaType) => root.appendRootMenu(source), + menuChanged$: this.menuChanged$, + mergeMenu: (source: MenuSchemaType, target?: MenuSchemaType) => this.mergeMenu(source, target), + appendRootMenu: (source: MenuSchemaType) => this.appendRootMenu(source), + updateMenuConfig: (config: MenuConfig) => this.updateMenuConfig(config), getMenuByPositionKey: (position: string) => createScopedBuilder().getMenuByPositionKey(position), getFlatMenuByPositionKey: (position: string) => createScopedBuilder().getFlatMenuByPositionKey(position), }; @@ -349,15 +364,18 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic private _buildMenuSchema(data: MenuSchemaType): IMenuSchema[] { const result: IMenuSchema[] = []; + const menuConfig = this._configService.getConfig('menu'); for (const [key, value] of Object.entries(data)) { if (key === 'replace') { continue; } + const item: IMenuItem | undefined = value.menuItemFactory ? this._injector.invoke(value.menuItemFactory) : undefined; + const config = menuConfig?.[item?.id ?? key]; const menuItem: Partial = { key, - order: value.order, + order: config?.order ?? value.order, title: value.title, contextual: value.contextual, quickLayout: value.quickLayout, @@ -365,22 +383,11 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic quickLayoutVariant: value.quickLayoutVariant, mobileLayout: value.mobileLayout, tiny: value.tiny, - gridLayout: value.gridLayout, + gridLayout: config?.gridLayout ? { ...value.gridLayout, ...config.gridLayout } : value.gridLayout, }; - if (value.menuItemFactory) { - const item: IMenuItem = this._injector.invoke(value.menuItemFactory); - - if (item) { - const menuItemConfig: Nullable = this._configService.getConfig('menu'); - - if (menuItemConfig && item.id in menuItemConfig) { - const _key = item.id as keyof IMenuItem; - menuItem.item = mergeMenuConfigs(item, menuItemConfig[_key] as any); - } else { - menuItem.item = item; - } - } + if (item) { + menuItem.item = config ? mergeMenuConfigs(item, config) : item; } if (value.headerActionMenuItemFactory) { menuItem.headerActionItem = this._injector.invoke(value.headerActionMenuItemFactory); diff --git a/packages/ui/src/services/menu/menu.ts b/packages/ui/src/services/menu/menu.ts index 45154af1a1..2164ae893e 100644 --- a/packages/ui/src/services/menu/menu.ts +++ b/packages/ui/src/services/menu/menu.ts @@ -17,6 +17,8 @@ import type { IAccessor } from '@univerjs/core'; import type { Observable } from 'rxjs'; +import type { IRibbonGridLayout } from './menu-manager.service'; + export type OneOrMany = T | T[]; export enum MenuItemType { @@ -144,6 +146,11 @@ export type MenuItemConfig = Partial; +/** Overrides keyed by menu item ID, ribbon tab key, or ribbon group key. */ export type MenuConfig = Record>; export type IMenuItemFactory = (accessor: IAccessor, menuConfig?: MenuConfig) => IMenuItem;