feat(ui): expose runtime menu configuration updates (#7718)

This commit is contained in:
白熱
2026-09-21 11:15:03 +08:00
committed by GitHub
parent 6597711576
commit 3d81f474b7
6 changed files with 214 additions and 41 deletions
@@ -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);
+15 -1
View File
@@ -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<string, (accessor: IAccessor) => 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;
}
}
/**
+52 -3
View File
@@ -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);
@@ -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 });
});
});
@@ -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<MenuConfig>('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<IMenuSchema> = {
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<IMenuItem> = this._configService.getConfig<IMenuItem>('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);
+7
View File
@@ -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 | T[];
export enum MenuItemType {
@@ -144,6 +146,11 @@ export type MenuItemConfig<TLocaleKey extends string = string> = Partial<Omit<IM
hidden?: boolean;
disabled?: boolean;
activated?: boolean;
/** Display order among sibling menu items, ribbon tabs, or groups. Lower values come first. */
order?: number;
/** Placement within a ribbon group when ribbonType is 'grid'. Rows and columns start at 1. */
gridLayout?: IRibbonGridLayout;
}>;
/** Overrides keyed by menu item ID, ribbon tab key, or ribbon group key. */
export type MenuConfig<TLocaleKey extends string = string> = Record<string, MenuItemConfig<TLocaleKey>>;
export type IMenuItemFactory<TLocaleKey extends string = string> = (accessor: IAccessor, menuConfig?: MenuConfig<TLocaleKey>) => IMenuItem<TLocaleKey>;