diff --git a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts index e1972603d9068..b334b75001ecc 100644 --- a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts +++ b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts @@ -225,36 +225,38 @@ export class SampleCommandContribution implements CommandContribution { @injectable() export class SampleMenuContribution implements MenuContribution { registerMenus(menus: MenuModelRegistry): void { - const subMenuPath = [...MAIN_MENU_BAR, 'sample-menu']; - menus.registerSubmenu(subMenuPath, 'Sample Menu', { - order: '2' // that should put the menu right next to the File menu - }); - menus.registerMenuAction(subMenuPath, { - commandId: SampleCommand.id, - order: '0' - }); - menus.registerMenuAction(subMenuPath, { - commandId: SampleCommand2.id, - order: '2' - }); - const subSubMenuPath = [...subMenuPath, 'sample-sub-menu']; - menus.registerSubmenu(subSubMenuPath, 'Sample sub menu', { order: '2' }); - menus.registerMenuAction(subSubMenuPath, { - commandId: SampleCommand.id, - order: '1' - }); - menus.registerMenuAction(subSubMenuPath, { - commandId: SampleCommand2.id, - order: '3' - }); - const placeholder = new PlaceholderMenuNode([...subSubMenuPath, 'placeholder'].join('-'), 'Placeholder', { order: '0' }); - menus.registerMenuNode(subSubMenuPath, placeholder); + setTimeout(() => { + const subMenuPath = [...MAIN_MENU_BAR, 'sample-menu']; + menus.registerSubmenu(subMenuPath, 'Sample Menu', { + order: '2' // that should put the menu right next to the File menu + }); + menus.registerMenuAction(subMenuPath, { + commandId: SampleCommand.id, + order: '0' + }); + menus.registerMenuAction(subMenuPath, { + commandId: SampleCommand2.id, + order: '2' + }); + const subSubMenuPath = [...subMenuPath, 'sample-sub-menu']; + menus.registerSubmenu(subSubMenuPath, 'Sample sub menu', { order: '2' }); + menus.registerMenuAction(subSubMenuPath, { + commandId: SampleCommand.id, + order: '1' + }); + menus.registerMenuAction(subSubMenuPath, { + commandId: SampleCommand2.id, + order: '3' + }); + const placeholder = new PlaceholderMenuNode([...subSubMenuPath, 'placeholder'].join('-'), 'Placeholder', { order: '0' }); + menus.registerMenuNode(subSubMenuPath, placeholder); - /** - * Register an action menu with an invalid command (un-registered and without a label) in order - * to determine that menus and the layout does not break on startup. - */ - menus.registerMenuAction(subMenuPath, { commandId: 'invalid-command' }); + /** + * Register an action menu with an invalid command (un-registered and without a label) in order + * to determine that menus and the layout does not break on startup. + */ + menus.registerMenuAction(subMenuPath, { commandId: 'invalid-command' }); + }, 10000); } } diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index d1ceac06b8220..c688de68f71b3 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -15,18 +15,20 @@ // ***************************************************************************** import { injectable, inject } from 'inversify'; -import { MenuBar, Menu as MenuWidget, Widget } from '@phosphor/widgets'; +import { Menu, MenuBar, Menu as MenuWidget, Widget } from '@phosphor/widgets'; import { CommandRegistry as PhosphorCommandRegistry } from '@phosphor/commands'; +import { ElementExt } from '@phosphor/domutils'; import { CommandRegistry, environment, DisposableCollection, Disposable, - MenuModelRegistry, MAIN_MENU_BAR, MenuPath, MenuNode, MenuCommandExecutor, CompoundMenuNode, CompoundMenuNodeRole, CommandMenuNode + MenuModelRegistry, MAIN_MENU_BAR, MenuPath, MenuNode, MenuCommandExecutor, CompoundMenuNode, CompoundMenuNodeRole, CommandMenuNode, + ArrayUtils } from '../../common'; import { KeybindingRegistry } from '../keybinding'; import { FrontendApplication } from '../frontend-application'; import { FrontendApplicationContribution } from '../frontend-application-contribution'; import { ContextKeyService, ContextMatcher } from '../context-key-service'; import { ContextMenuContext } from './context-menu-context'; -import { waitForRevealed } from '../widgets'; +import { Message, waitForRevealed } from '../widgets'; import { ApplicationShell } from '../shell'; import { CorePreferences } from '../core-preferences'; import { PreferenceService } from '../preferences/preference-service'; @@ -82,8 +84,10 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { this.keybindingRegistry.onKeybindingsChanged(() => { this.showMenuBar(menuBar); }), - this.menuProvider.onDidChange(() => { - this.showMenuBar(menuBar); + this.menuProvider.onDidChange(evt => { + if (ArrayUtils.startsWith(evt.path, MAIN_MENU_BAR)) { + this.showMenuBar(menuBar); + } }) ); menuBar.disposed.connect(() => disposable.dispose()); @@ -156,6 +160,10 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { } +export function isMenuElement(element: HTMLElement | null): boolean { + return !!element && element.className.includes('p-Menu'); +} + export class DynamicMenuBarWidget extends MenuBarWidget { /** @@ -263,6 +271,48 @@ export class DynamicMenuWidget extends MenuWidget { this.updateSubMenus(this, this.menu, this.options.commands); } + protected override onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + this.node.ownerDocument.addEventListener('pointerdown', this, true); + } + + protected override onBeforeDetach(msg: Message): void { + this.node.ownerDocument.removeEventListener('pointerdown', this); + super.onAfterDetach(msg); + } + + override handleEvent(event: Event): void { + if (event.type === 'pointerdown') { + this.handlePointerDown(event as PointerEvent); + } + super.handleEvent(event); + } + + handlePointerDown(event: PointerEvent): void { + // this code is copied from the superclass because we cannot use the hit + // test from the "Private" implementation namespace + if (this['_parentMenu']) { + return; + } + + // The mouse button which is pressed is irrelevant. If the press + // is not on a menu, the entire hierarchy is closed and the event + // is allowed to propagate. This allows other code to act on the + // event, such as focusing the clicked element. + if (!this.hitTestMenus(this, event.clientX, event.clientY)) { + this.close(); + } + } + + private hitTestMenus(menu: Menu, x: number, y: number): boolean { + for (let temp: Menu | null = menu; temp; temp = temp.childMenu) { + if (ElementExt.hitTest(temp.node, x, y)) { + return true; + } + } + return false; + } + public aboutToShow({ previousFocusedElement }: { previousFocusedElement: HTMLElement | undefined }): void { this.preserveFocusedElement(previousFocusedElement); this.clearItems(); diff --git a/packages/core/src/common/array-utils.ts b/packages/core/src/common/array-utils.ts index 0d50e35058db0..fab52833c2196 100644 --- a/packages/core/src/common/array-utils.ts +++ b/packages/core/src/common/array-utils.ts @@ -126,4 +126,29 @@ export namespace ArrayUtils { } return result; } + + export function shallowEqual(left: readonly T[], right: readonly T[]): boolean { + if (left.length !== right.length) { + return false; + } + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) { + return false; + } + } + return true; + } + + export function startsWith(left: readonly T[], right: readonly T[]): boolean { + if (right.length > left.length) { + return false; + } + + for (let i = 0; i < right.length; i++) { + if (left[i] !== right[i]) { + return false; + } + } + return true; + } } diff --git a/packages/core/src/common/menu/composite-menu-node.ts b/packages/core/src/common/menu/composite-menu-node.ts index afc1819a3b058..4c5751e1f7ab2 100644 --- a/packages/core/src/common/menu/composite-menu-node.ts +++ b/packages/core/src/common/menu/composite-menu-node.ts @@ -54,11 +54,13 @@ export class CompositeMenuNode implements MutableCompoundMenuNode { }; } - removeNode(id: string): void { + removeNode(id: string): boolean { const idx = this._children.findIndex(n => n.id === id); if (idx >= 0) { this._children.splice(idx, 1); + return true; } + return false; } updateOptions(options?: SubMenuOptions): void { @@ -108,7 +110,7 @@ export class CompositeMenuNodeWrapper implements MutableCompoundMenuNode { addNode(node: MenuNode): Disposable { return this.wrapped.addNode(node); } - removeNode(id: string): void { return this.wrapped.removeNode(id); } + removeNode(id: string): boolean { return this.wrapped.removeNode(id); } updateOptions(options: SubMenuOptions): void { return this.wrapped.updateOptions(options); } } diff --git a/packages/core/src/common/menu/menu-model-registry.ts b/packages/core/src/common/menu/menu-model-registry.ts index e321440c45170..d2cea1522ac73 100644 --- a/packages/core/src/common/menu/menu-model-registry.ts +++ b/packages/core/src/common/menu/menu-model-registry.ts @@ -59,6 +59,29 @@ export interface MenuContribution { registerMenus(menus: MenuModelRegistry): void; } +export enum ChangeKind { + ADDED, + REMOVED, + CHANGED, + LINKED +} + +export interface MenuChangedEvent { + kind: ChangeKind; + path: MenuPath +} + +export interface StructuralMenuChange extends MenuChangedEvent { + kind: ChangeKind.ADDED | ChangeKind.REMOVED | ChangeKind.LINKED; + affectedChildId: string +} + +export namespace StructuralMenuChange { + export function is(evt: MenuChangedEvent): evt is StructuralMenuChange { + return evt.kind !== ChangeKind.CHANGED; + } +} + /** * The MenuModelRegistry allows to register and unregister menus, submenus and actions * via strings and {@link MenuAction}s without the need to access the underlying UI @@ -69,9 +92,9 @@ export class MenuModelRegistry { protected readonly root = new CompositeMenuNode(''); protected readonly independentSubmenus = new Map(); - protected readonly onDidChangeEmitter = new Emitter(); + protected readonly onDidChangeEmitter = new Emitter(); - get onDidChange(): Event { + get onDidChange(): Event { return this.onDidChangeEmitter.event; } @@ -108,8 +131,21 @@ export class MenuModelRegistry { registerMenuNode(menuPath: MenuPath | string, menuNode: MenuNode, group?: string): Disposable { const parent = this.getMenuNode(menuPath, group); const disposable = parent.addNode(menuNode); - this.fireChangeEvent(); - return this.changeEventOnDispose(disposable); + const parentPath = this.getParentPath(menuPath, group); + this.fireChangeEvent({ + kind: ChangeKind.ADDED, + path: parentPath, + affectedChildId: menuNode.id + }); + return this.changeEventOnDispose(parentPath, menuNode.id, disposable); + } + + protected getParentPath(menuPath: MenuPath | string, group?: string): string[] { + if (typeof menuPath === 'string') { + return group ? [menuPath, group] : [menuPath]; + } else { + return group ? menuPath.concat(group) : menuPath; + } } getMenuNode(menuPath: MenuPath | string, group?: string): MutableCompoundMenuNode { @@ -152,11 +188,19 @@ export class MenuModelRegistry { let disposable = Disposable.NULL; if (!groupNode) { groupNode = new CompositeMenuNode(menuId, label, options, parent); - disposable = this.changeEventOnDispose(parent.addNode(groupNode)); + disposable = this.changeEventOnDispose(groupPath, menuId, parent.addNode(groupNode)); + this.fireChangeEvent({ + kind: ChangeKind.ADDED, + path: groupPath, + affectedChildId: menuId + }); } else { + this.fireChangeEvent({ + kind: ChangeKind.CHANGED, + path: groupPath, + }); groupNode.updateOptions({ ...options, label }); } - this.fireChangeEvent(); return disposable; } @@ -165,12 +209,13 @@ export class MenuModelRegistry { console.debug(`Independent submenu with path ${id} registered, but given ID already exists.`); } this.independentSubmenus.set(id, new CompositeMenuNode(id, label, options)); - return this.changeEventOnDispose(Disposable.create(() => this.independentSubmenus.delete(id))); + return this.changeEventOnDispose([], id, Disposable.create(() => this.independentSubmenus.delete(id))); } linkSubmenu(parentPath: MenuPath | string, childId: string | MenuPath, options?: SubMenuOptions, group?: string): Disposable { const child = this.getMenuNode(childId); const parent = this.getMenuNode(parentPath, group); + const affectedPath = this.getParentPath(parentPath, group); const isRecursive = (node: MenuNodeMetadata, childNode: MenuNodeMetadata): boolean => { if (node.id === childNode.id) { @@ -190,8 +235,13 @@ export class MenuModelRegistry { const wrapper = new CompositeMenuNodeWrapper(child, parent, options); const disposable = parent.addNode(wrapper); - this.fireChangeEvent(); - return this.changeEventOnDispose(disposable); + this.fireChangeEvent({ + kind: ChangeKind.LINKED, + path: affectedPath, + affectedChildId: child.id + + }); + return this.changeEventOnDispose(affectedPath, child.id, disposable); } /** @@ -223,11 +273,14 @@ export class MenuModelRegistry { if (menuPath) { const parent = this.findGroup(menuPath); parent.removeNode(id); - this.fireChangeEvent(); - return; + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: menuPath, + affectedChildId: id + }); + } else { + this.unregisterMenuNode(id); } - - this.unregisterMenuNode(id); } /** @@ -236,16 +289,24 @@ export class MenuModelRegistry { * @param id technical identifier of the `MenuNode`. */ unregisterMenuNode(id: string): void { + const parentPath: string[] = []; const recurse = (root: MutableCompoundMenuNode) => { root.children.forEach(node => { if (CompoundMenuNode.isMutable(node)) { - node.removeNode(id); + if (node.removeNode(id)) { + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: parentPath, + affectedChildId: id + }); + } + parentPath.push(node.id); recurse(node); + parentPath.pop(); } }); }; recurse(this.root); - this.fireChangeEvent(); } /** @@ -339,16 +400,20 @@ export class MenuModelRegistry { return true; } - protected changeEventOnDispose(disposable: Disposable): Disposable { + protected changeEventOnDispose(path: MenuPath, id: string, disposable: Disposable): Disposable { return Disposable.create(() => { disposable.dispose(); - this.fireChangeEvent(); + this.fireChangeEvent({ + path, + affectedChildId: id, + kind: ChangeKind.REMOVED + }); }); } - protected fireChangeEvent(): void { + protected fireChangeEvent(evt: T): void { if (this.isReady) { - this.onDidChangeEmitter.fire(); + this.onDidChangeEmitter.fire(evt); } } diff --git a/packages/core/src/common/menu/menu-types.ts b/packages/core/src/common/menu/menu-types.ts index b3a443816a010..9e8b19e5fa195 100644 --- a/packages/core/src/common/menu/menu-types.ts +++ b/packages/core/src/common/menu/menu-types.ts @@ -139,8 +139,9 @@ export interface MutableCompoundMenuNode extends CompoundMenuNode { * Removes the first node with the given id. * * @param id node id. + * @returns true if the id was present */ - removeNode(id: string): void; + removeNode(id: string): boolean; /** * Fills any `undefined` fields with the values from the {@link options}.