From fa87e67ebfd6b8014ba697ddbc6c2d6c0e2cb970 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 4 Dec 2018 10:10:11 +0100 Subject: [PATCH] Revert "Make menubar its own widget and add overflow (#63954)" This reverts commit aedd4402fb7eb0022589012f440b059c6b12f12b. --- src/vs/base/browser/ui/actionbar/actionbar.ts | 18 +- src/vs/base/browser/ui/menu/ellipsis.svg | 1 - src/vs/base/browser/ui/menu/menu.css | 60 -- src/vs/base/browser/ui/menu/menu.ts | 49 +- src/vs/base/browser/ui/menu/menubar.ts | 964 ------------------ .../parts/titlebar/media/titlebarpart.css | 45 + .../browser/parts/titlebar/menubarControl.ts | 834 +++++++++++++-- 7 files changed, 798 insertions(+), 1173 deletions(-) delete mode 100644 src/vs/base/browser/ui/menu/ellipsis.svg delete mode 100644 src/vs/base/browser/ui/menu/menubar.ts diff --git a/src/vs/base/browser/ui/actionbar/actionbar.ts b/src/vs/base/browser/ui/actionbar/actionbar.ts index c6c24af7aee..08a67171915 100644 --- a/src/vs/base/browser/ui/actionbar/actionbar.ts +++ b/src/vs/base/browser/ui/actionbar/actionbar.ts @@ -675,28 +675,12 @@ export class ActionBar extends Disposable implements IActionRunner { return this.items.length === 0; } - focus(index?: number): void; - focus(selectFirst?: boolean): void; - focus(arg?: any): void { - let selectFirst: boolean = false; - let index: number | undefined = void 0; - if (arg === undefined) { - selectFirst = true; - } else if (typeof arg === 'number') { - index = arg; - } else if (typeof arg === 'boolean') { - selectFirst = arg; - } - + focus(selectFirst?: boolean): void { if (selectFirst && typeof this.focusedItem === 'undefined') { // Focus the first enabled item this.focusedItem = this.items.length - 1; this.focusNext(); } else { - if (index !== undefined) { - this.focusedItem = index; - } - this.updateFocus(); } } diff --git a/src/vs/base/browser/ui/menu/ellipsis.svg b/src/vs/base/browser/ui/menu/ellipsis.svg deleted file mode 100644 index e3f85623356..00000000000 --- a/src/vs/base/browser/ui/menu/ellipsis.svg +++ /dev/null @@ -1 +0,0 @@ -Ellipsis_bold_16x \ No newline at end of file diff --git a/src/vs/base/browser/ui/menu/menu.css b/src/vs/base/browser/ui/menu/menu.css index b896e7bed28..c24040ba3a3 100644 --- a/src/vs/base/browser/ui/menu/menu.css +++ b/src/vs/base/browser/ui/menu/menu.css @@ -145,64 +145,4 @@ .hc-black .monaco-menu .monaco-action-bar.vertical .action-item.focused { background: none; -} - -/* Menubar styles */ - -.menubar { - display: flex; - flex-shrink: 1; - box-sizing: border-box; - height: 30px; - -webkit-app-region: no-drag; - overflow: hidden; - flex-wrap: wrap; -} - -.fullscreen .menubar { - margin: 0px; - padding: 0px 5px; -} - -.menubar > .menubar-menu-button { - align-items: center; - box-sizing: border-box; - padding: 0px 8px; - cursor: default; - -webkit-app-region: no-drag; - zoom: 1; - white-space: nowrap; - outline: 0; -} - -.menubar .menubar-menu-items-holder { - position: absolute; - left: 0px; - opacity: 1; - z-index: 2000; -} - -.menubar .menubar-menu-items-holder.monaco-menu-container { - font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "HelveticaNeue-Light", "Ubuntu", "Droid Sans", sans-serif; - outline: 0; - border: none; -} - -.menubar .menubar-menu-items-holder.monaco-menu-container :focus { - outline: 0; -} - -.menubar .toolbar-toggle-more { - background-position: center; - background-repeat: no-repeat; - background-size: 14px; - width: 20px; - height: 100%; -} - -.menubar .toolbar-toggle-more { - display: inline-block; - padding: 0; - -webkit-mask: url('ellipsis.svg') no-repeat 50% 55%/14px 14px; - mask: url('ellipsis.svg') no-repeat 50% 55%/14px 14px; } \ No newline at end of file diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index 99aa8227783..5abc7332d4a 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -9,7 +9,7 @@ import * as strings from 'vs/base/common/strings'; import { IActionRunner, IAction, Action } from 'vs/base/common/actions'; import { ActionBar, IActionItemProvider, ActionsOrientation, Separator, ActionItem, IActionItemOptions, BaseActionItem } from 'vs/base/browser/ui/actionbar/actionbar'; import { ResolvedKeybinding, KeyCode, KeyCodeUtils } from 'vs/base/common/keyCodes'; -import { addClass, EventType, EventHelper, EventLike, removeTabIndexAndUpdateFocus, isAncestor, hasClass, addDisposableListener, removeClass, append, $, addClasses, removeClasses } from 'vs/base/browser/dom'; +import { addClass, EventType, EventHelper, EventLike, removeTabIndexAndUpdateFocus, isAncestor, hasClass, addDisposableListener, removeClass, append, $, addClasses, getClientArea, removeClasses } from 'vs/base/browser/dom'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { RunOnceScheduler } from 'vs/base/common/async'; import { IDisposable, dispose } from 'vs/base/common/lifecycle'; @@ -209,7 +209,7 @@ export class Menu extends ActionBar { return this.scrollableElement.getDomNode(); } - get onScroll(): Event { + public get onScroll(): Event { return this._onScroll.event; } @@ -217,20 +217,6 @@ export class Menu extends ActionBar { return this.menuElement.scrollTop; } - trigger(index: number): void { - if (index <= this.items.length && index >= 0) { - const item = this.items[index]; - if (item instanceof SubmenuActionItem) { - super.focus(index); - item.open(true); - } else if (item instanceof MenuActionItem) { - super.run(item._action, item._context); - } else { - return; - } - } - } - private focusItemByElement(element: HTMLElement) { const lastFocusedItem = this.focusedItem; this.setFocusedItem(element); @@ -299,6 +285,10 @@ export class Menu extends ActionBar { return menuActionItem; } } + + public focus(selectFirst = true) { + super.focus(selectFirst); + } } interface IMenuItemOptions extends IActionItemOptions { @@ -581,11 +571,6 @@ class SubmenuActionItem extends MenuActionItem { })); } - open(selectFirst?: boolean): void { - this.cleanupExistingSubmenu(false); - this.createSubmenu(selectFirst); - } - onClick(e: EventLike): void { // stop clicking from trying to run an action EventHelper.stop(e, true); @@ -610,22 +595,8 @@ class SubmenuActionItem extends MenuActionItem { if (!this.parentData.submenu) { this.submenuContainer = append(this.element, $('div.monaco-submenu')); addClasses(this.submenuContainer, 'menubar-menu-items-holder', 'context-view'); - - this.parentData.submenu = new Menu(this.submenuContainer, this.submenuActions, this.submenuOptions); - if (this.menuStyle) { - this.parentData.submenu.style(this.menuStyle); - } - - const boundingRect = this.element.getBoundingClientRect(); - const childBoundingRect = this.submenuContainer.getBoundingClientRect(); - - if (window.innerWidth <= boundingRect.right + childBoundingRect.width) { - this.submenuContainer.style.left = '10px'; - this.submenuContainer.style.top = `${this.element.offsetTop - this.parentData.parent.scrollOffset + boundingRect.height}px`; - } else { - this.submenuContainer.style.left = `${this.element.offsetWidth}px`; - this.submenuContainer.style.top = `${this.element.offsetTop - this.parentData.parent.scrollOffset}px`; - } + this.submenuContainer.style.left = `${getClientArea(this.element).width}px`; + this.submenuContainer.style.top = `${this.element.offsetTop - this.parentData.parent.scrollOffset}px`; this.submenuDisposables.push(addDisposableListener(this.submenuContainer, EventType.KEY_UP, e => { let event = new StandardKeyboardEvent(e); @@ -648,6 +619,10 @@ class SubmenuActionItem extends MenuActionItem { } })); + this.parentData.submenu = new Menu(this.submenuContainer, this.submenuActions, this.submenuOptions); + if (this.menuStyle) { + this.parentData.submenu.style(this.menuStyle); + } this.submenuDisposables.push(this.parentData.submenu.onDidCancel(() => { this.parentData.parent.focus(); diff --git a/src/vs/base/browser/ui/menu/menubar.ts b/src/vs/base/browser/ui/menu/menubar.ts deleted file mode 100644 index 5d916ccaf05..00000000000 --- a/src/vs/base/browser/ui/menu/menubar.ts +++ /dev/null @@ -1,964 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as browser from 'vs/base/browser/browser'; -import * as DOM from 'vs/base/browser/dom'; -import * as strings from 'vs/base/common/strings'; -import * as nls from 'vs/nls'; -import { domEvent } from 'vs/base/browser/event'; -import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { EventType, Gesture, GestureEvent } from 'vs/base/browser/touch'; -import { cleanMnemonic, IMenuOptions, Menu, MENU_ESCAPED_MNEMONIC_REGEX, MENU_MNEMONIC_REGEX, SubmenuAction, IMenuStyles } from 'vs/base/browser/ui/menu/menu'; -import { ActionRunner, IAction, IActionRunner } from 'vs/base/common/actions'; -import { RunOnceScheduler } from 'vs/base/common/async'; -import { Event, Emitter } from 'vs/base/common/event'; -import { KeyCode, KeyCodeUtils, ResolvedKeybinding } from 'vs/base/common/keyCodes'; -import { Disposable, dispose, IDisposable } from 'vs/base/common/lifecycle'; - -const $ = DOM.$; - -export interface IMenuBarOptions { - enableMnemonics?: boolean; - visibility?: string; - getKeybinding?: (action: IAction) => ResolvedKeybinding; -} - -export interface MenuBarMenu { - actions: IAction[]; - label: string; -} - -enum MenubarState { - HIDDEN, - VISIBLE, - FOCUSED, - OPEN -} - -export class MenuBar extends Disposable { - - static readonly OVERFLOW_INDEX: number = -1; - - private menuCache: { - buttonElement: HTMLElement; - titleElement: HTMLElement; - label: string; - actions?: IAction[]; - }[]; - - private overflowMenu: { - buttonElement: HTMLElement; - titleElement: HTMLElement; - label: string; - actions?: IAction[]; - }; - - private focusedMenu: { - index: number; - holder?: HTMLElement; - widget?: Menu; - }; - - private focusToReturn: HTMLElement; - private menuUpdater: RunOnceScheduler; - - // Input-related - private _mnemonicsInUse: boolean; - private openedViaKeyboard: boolean; - private awaitingAltRelease: boolean; - private ignoreNextMouseUp: boolean; - private mnemonics: Map; - - private updatePending: boolean; - private _focusState: MenubarState; - private actionRunner: IActionRunner; - - private _onVisibilityChange: Emitter; - private _onFocusStateChange: Emitter; - - private numMenusShown: number; - private menuStyle: IMenuStyles; - - constructor(private container: HTMLElement, private options: IMenuBarOptions = {}) { - super(); - - this.container.attributes['role'] = 'menubar'; - - this.menuCache = []; - this.mnemonics = new Map(); - - this._onVisibilityChange = this._register(new Emitter()); - this._onFocusStateChange = this._register(new Emitter()); - - this.createOverflowMenu(); - - this.menuUpdater = this._register(new RunOnceScheduler(() => this.update(), 200)); - - this.actionRunner = this._register(new ActionRunner()); - this._register(this.actionRunner.onDidBeforeRun(() => { - this.setUnfocusedState(); - })); - - this._register(ModifierKeyEmitter.getInstance().event(this.onModifierKeyToggled, this)); - - this._register(DOM.addDisposableListener(this.container, DOM.EventType.KEY_DOWN, (e) => { - let event = new StandardKeyboardEvent(e as KeyboardEvent); - let eventHandled = true; - const key = !!e.key ? KeyCodeUtils.fromString(e.key) : KeyCode.Unknown; - - if (event.equals(KeyCode.LeftArrow)) { - this.focusPrevious(); - } else if (event.equals(KeyCode.RightArrow)) { - this.focusNext(); - } else if (event.equals(KeyCode.Escape) && this.isFocused && !this.isOpen) { - this.setUnfocusedState(); - } else if (!this.isOpen && !event.ctrlKey && this.options.enableMnemonics && this.mnemonicsInUse && this.mnemonics.has(key)) { - const menuIndex = this.mnemonics.get(key); - this.onMenuTriggered(menuIndex, false); - } else { - eventHandled = false; - } - - if (eventHandled) { - event.preventDefault(); - event.stopPropagation(); - } - })); - - this._register(DOM.addDisposableListener(window, DOM.EventType.MOUSE_DOWN, () => { - // This mouse event is outside the menubar so it counts as a focus out - if (this.isFocused) { - this.setUnfocusedState(); - } - })); - - this._register(DOM.addDisposableListener(this.container, DOM.EventType.FOCUS_IN, (e) => { - let event = e as FocusEvent; - - if (event.relatedTarget) { - if (!this.container.contains(event.relatedTarget as HTMLElement)) { - this.focusToReturn = event.relatedTarget as HTMLElement; - } - } - })); - - this._register(DOM.addDisposableListener(this.container, DOM.EventType.FOCUS_OUT, (e) => { - let event = e as FocusEvent; - - if (event.relatedTarget) { - if (!this.container.contains(event.relatedTarget as HTMLElement)) { - this.focusToReturn = null; - this.setUnfocusedState(); - } - } - })); - - this._register(DOM.addDisposableListener(window, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (!this.options.enableMnemonics || !e.altKey || e.ctrlKey || e.defaultPrevented) { - return; - } - - const key = KeyCodeUtils.fromString(e.key); - if (!this.mnemonics.has(key)) { - return; - } - - this.mnemonicsInUse = true; - this.updateMnemonicVisibility(true); - - const menuIndex = this.mnemonics.get(key); - this.onMenuTriggered(menuIndex, false); - })); - - this.setUnfocusedState(); - } - - push(arg: MenuBarMenu | MenuBarMenu[]): void { - const menus: MenuBarMenu[] = !Array.isArray(arg) ? [arg] : arg; - - menus.forEach((menuBarMenu) => { - const menuIndex = this.menuCache.length; - const cleanMenuLabel = cleanMnemonic(menuBarMenu.label); - - const buttonElement = $('div.menubar-menu-button', { 'role': 'menuitem', 'tabindex': -1, 'aria-label': cleanMenuLabel, 'aria-haspopup': true }); - const titleElement = $('div.menubar-menu-title', { 'role': 'none', 'aria-hidden': true }); - - buttonElement.appendChild(titleElement); - this.container.insertBefore(buttonElement, this.overflowMenu.buttonElement); - - let mnemonicMatches = MENU_MNEMONIC_REGEX.exec(menuBarMenu.label); - - // Register mnemonics - if (mnemonicMatches) { - let mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[2]; - - this.registerMnemonic(this.menuCache.length, mnemonic); - } - - this.updateLabels(titleElement, buttonElement, menuBarMenu.label); - - this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.KEY_UP, (e) => { - let event = new StandardKeyboardEvent(e as KeyboardEvent); - let eventHandled = true; - - if ((event.equals(KeyCode.DownArrow) || event.equals(KeyCode.Enter)) && !this.isOpen) { - this.focusedMenu = { index: menuIndex }; - this.openedViaKeyboard = true; - this.focusState = MenubarState.OPEN; - } else { - eventHandled = false; - } - - if (eventHandled) { - event.preventDefault(); - event.stopPropagation(); - } - })); - - Gesture.addTarget(buttonElement); - this._register(DOM.addDisposableListener(buttonElement, EventType.Tap, (e: GestureEvent) => { - // Ignore this touch if the menu is touched - if (this.isOpen && this.focusedMenu.holder && DOM.isAncestor(e.initialTarget as HTMLElement, this.focusedMenu.holder)) { - return; - } - - this.ignoreNextMouseUp = false; - this.onMenuTriggered(menuIndex, true); - - e.preventDefault(); - e.stopPropagation(); - })); - - this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_DOWN, (e) => { - if (!this.isOpen) { - // Open the menu with mouse down and ignore the following mouse up event - this.ignoreNextMouseUp = true; - this.onMenuTriggered(menuIndex, true); - } else { - this.ignoreNextMouseUp = false; - } - - e.preventDefault(); - e.stopPropagation(); - })); - - this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_UP, (e) => { - if (!this.ignoreNextMouseUp) { - if (this.isFocused) { - this.onMenuTriggered(menuIndex, true); - } - } else { - this.ignoreNextMouseUp = false; - } - })); - - this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_ENTER, () => { - if (this.isOpen && !this.isCurrentMenu(menuIndex)) { - this.menuCache[menuIndex].buttonElement.focus(); - this.cleanupCustomMenu(); - this.showCustomMenu(menuIndex, false); - } else if (this.isFocused && !this.isOpen) { - this.focusedMenu = { index: menuIndex }; - buttonElement.focus(); - } - })); - - this.menuCache.push({ - label: menuBarMenu.label, - actions: menuBarMenu.actions, - buttonElement: buttonElement, - titleElement: titleElement - }); - }); - } - - createOverflowMenu(): void { - const label = nls.localize('mMore', "..."); - const buttonElement = $('div.menubar-menu-button', { 'role': 'menuitem', 'tabindex': -1, 'aria-label': label, 'aria-haspopup': true }); - const titleElement = $('div.menubar-menu-title.toolbar-toggle-more', { 'role': 'none', 'aria-hidden': true }); - - buttonElement.appendChild(titleElement); - this.container.appendChild(buttonElement); - buttonElement.style.visibility = 'hidden'; - - this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.KEY_UP, (e) => { - let event = new StandardKeyboardEvent(e as KeyboardEvent); - let eventHandled = true; - - if ((event.equals(KeyCode.DownArrow) || event.equals(KeyCode.Enter)) && !this.isOpen) { - this.focusedMenu = { index: MenuBar.OVERFLOW_INDEX }; - this.openedViaKeyboard = true; - this.focusState = MenubarState.OPEN; - } else { - eventHandled = false; - } - - if (eventHandled) { - event.preventDefault(); - event.stopPropagation(); - } - })); - - Gesture.addTarget(buttonElement); - this._register(DOM.addDisposableListener(buttonElement, EventType.Tap, (e: GestureEvent) => { - // Ignore this touch if the menu is touched - if (this.isOpen && this.focusedMenu.holder && DOM.isAncestor(e.initialTarget as HTMLElement, this.focusedMenu.holder)) { - return; - } - - this.ignoreNextMouseUp = false; - this.onMenuTriggered(MenuBar.OVERFLOW_INDEX, true); - - e.preventDefault(); - e.stopPropagation(); - })); - - this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_DOWN, (e) => { - if (!this.isOpen) { - // Open the menu with mouse down and ignore the following mouse up event - this.ignoreNextMouseUp = true; - this.onMenuTriggered(MenuBar.OVERFLOW_INDEX, true); - } else { - this.ignoreNextMouseUp = false; - } - - e.preventDefault(); - e.stopPropagation(); - })); - - this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_UP, (e) => { - if (!this.ignoreNextMouseUp) { - if (this.isFocused) { - this.onMenuTriggered(MenuBar.OVERFLOW_INDEX, true); - } - } else { - this.ignoreNextMouseUp = false; - } - })); - - this._register(DOM.addDisposableListener(buttonElement, DOM.EventType.MOUSE_ENTER, () => { - if (this.isOpen && !this.isCurrentMenu(MenuBar.OVERFLOW_INDEX)) { - this.overflowMenu.buttonElement.focus(); - this.cleanupCustomMenu(); - this.showCustomMenu(MenuBar.OVERFLOW_INDEX, false); - } else if (this.isFocused && !this.isOpen) { - this.focusedMenu = { index: MenuBar.OVERFLOW_INDEX }; - buttonElement.focus(); - } - })); - - this.overflowMenu = { - buttonElement: buttonElement, - titleElement: titleElement, - label: 'More' - }; - } - - updateMenu(menu: MenuBarMenu): void { - const menuToUpdate = this.menuCache.filter(menuBarMenu => menuBarMenu.label === menu.label); - if (menuToUpdate && menuToUpdate.length) { - menuToUpdate[0].actions = menu.actions; - } - } - - dispose(): void { - super.dispose(); - - this.menuCache.forEach(menuBarMenu => { - DOM.removeNode(menuBarMenu.titleElement); - DOM.removeNode(menuBarMenu.buttonElement); - }); - - DOM.removeNode(this.overflowMenu.titleElement); - DOM.removeNode(this.overflowMenu.buttonElement); - } - - blur(): void { - this.setUnfocusedState(); - } - - getWidth(): number { - if (this.menuCache) { - const left = this.menuCache[0].buttonElement.getBoundingClientRect().left; - const right = this.hasOverflow ? this.overflowMenu.buttonElement.getBoundingClientRect().right : this.menuCache[this.menuCache.length - 1].buttonElement.getBoundingClientRect().right; - return right - left; - } - - return 0; - } - - getHeight(): number { - return this.container.clientHeight; - } - - private updateOverflowAction(): void { - if (!this.menuCache || !this.menuCache.length) { - return; - } - - const sizeAvailable = this.container.offsetWidth; - let currentSize = 0; - let full = false; - const prevNumMenusShown = this.numMenusShown; - this.numMenusShown = 0; - for (let menuBarMenu of this.menuCache) { - if (!full) { - const size = menuBarMenu.buttonElement.offsetWidth; - if (currentSize + size > sizeAvailable) { - full = true; - } else { - currentSize += size; - this.numMenusShown++; - if (this.numMenusShown > prevNumMenusShown) { - menuBarMenu.buttonElement.style.visibility = 'visible'; - } - } - } - - if (full) { - menuBarMenu.buttonElement.style.visibility = 'hidden'; - } - } - - // Overflow - if (full) { - // Can't fit the more button, need to remove more menus - while (currentSize + this.overflowMenu.buttonElement.offsetWidth > sizeAvailable && this.numMenusShown > 0) { - this.numMenusShown--; - const size = this.menuCache[this.numMenusShown].buttonElement.offsetWidth; - this.menuCache[this.numMenusShown].buttonElement.style.visibility = 'hidden'; - currentSize -= size; - } - - this.overflowMenu.actions = []; - for (let idx = this.numMenusShown; idx < this.menuCache.length; idx++) { - this.overflowMenu.actions.push(new SubmenuAction(this.menuCache[idx].label, this.menuCache[idx].actions)); - } - - DOM.removeNode(this.overflowMenu.buttonElement); - this.container.insertBefore(this.overflowMenu.buttonElement, this.menuCache[this.numMenusShown].buttonElement); - this.overflowMenu.buttonElement.style.visibility = 'visible'; - } else { - DOM.removeNode(this.overflowMenu.buttonElement); - this.container.appendChild(this.overflowMenu.buttonElement); - this.overflowMenu.buttonElement.style.visibility = 'hidden'; - } - } - - private updateLabels(titleElement: HTMLElement, buttonElement: HTMLElement, label: string): void { - const cleanMenuLabel = cleanMnemonic(label); - - // Update the button label to reflect mnemonics - titleElement.innerHTML = this.options.enableMnemonics ? - strings.escape(label).replace(MENU_ESCAPED_MNEMONIC_REGEX, '') : - cleanMenuLabel; - - let mnemonicMatches = MENU_MNEMONIC_REGEX.exec(label); - - // Register mnemonics - if (mnemonicMatches) { - let mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[2]; - - if (this.options.enableMnemonics) { - buttonElement.setAttribute('aria-keyshortcuts', 'Alt+' + mnemonic.toLocaleLowerCase()); - } else { - buttonElement.removeAttribute('aria-keyshortcuts'); - } - } - } - - style(style: IMenuStyles): void { - this.menuStyle = style; - } - - update(options?: IMenuBarOptions): void { - if (options) { - this.options = options; - } - - // Don't update while using the menu - if (this.isFocused) { - this.updatePending = true; - return; - } - - this.menuCache.forEach(menuBarMenu => { - this.updateLabels(menuBarMenu.titleElement, menuBarMenu.buttonElement, menuBarMenu.label); - }); - - this.updateOverflowAction(); - - this.setUnfocusedState(); - } - - private registerMnemonic(menuIndex: number, mnemonic: string): void { - this.mnemonics.set(KeyCodeUtils.fromString(mnemonic), menuIndex); - } - - private hideMenubar(): void { - if (this.container.style.display !== 'none') { - this.container.style.display = 'none'; - this._onVisibilityChange.fire(false); - } - } - - private showMenubar(): void { - if (this.container.style.display !== 'flex') { - this.container.style.display = 'flex'; - this._onVisibilityChange.fire(true); - } - } - - private get focusState(): MenubarState { - return this._focusState; - } - - private set focusState(value: MenubarState) { - if (this._focusState >= MenubarState.FOCUSED && value < MenubarState.FOCUSED) { - // Losing focus, update the menu if needed - - if (this.updatePending) { - this.menuUpdater.schedule(); - this.updatePending = false; - } - } - - if (value === this._focusState) { - return; - } - - const isVisible = this.isVisible; - const isOpen = this.isOpen; - const isFocused = this.isFocused; - - this._focusState = value; - - switch (value) { - case MenubarState.HIDDEN: - if (isVisible) { - this.hideMenubar(); - } - - if (isOpen) { - this.cleanupCustomMenu(); - } - - if (isFocused) { - this.focusedMenu = null; - - if (this.focusToReturn) { - this.focusToReturn.focus(); - this.focusToReturn = null; - } - } - - - break; - case MenubarState.VISIBLE: - if (!isVisible) { - this.showMenubar(); - } - - if (isOpen) { - this.cleanupCustomMenu(); - } - - if (isFocused) { - if (this.focusedMenu) { - if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) { - this.overflowMenu.buttonElement.blur(); - } else { - this.menuCache[this.focusedMenu.index].buttonElement.blur(); - } - } - - this.focusedMenu = null; - - if (this.focusToReturn) { - this.focusToReturn.focus(); - this.focusToReturn = null; - } - } - - break; - case MenubarState.FOCUSED: - if (!isVisible) { - this.showMenubar(); - } - - if (isOpen) { - this.cleanupCustomMenu(); - } - - if (this.focusedMenu) { - if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) { - this.overflowMenu.buttonElement.focus(); - } else { - this.menuCache[this.focusedMenu.index].buttonElement.focus(); - } - } - break; - case MenubarState.OPEN: - if (!isVisible) { - this.showMenubar(); - } - - if (this.focusedMenu) { - this.showCustomMenu(this.focusedMenu.index, this.openedViaKeyboard); - } - break; - } - - this._focusState = value; - this._onFocusStateChange.fire(this.focusState >= MenubarState.FOCUSED); - } - - private get isVisible(): boolean { - return this.focusState >= MenubarState.VISIBLE; - } - - private get isFocused(): boolean { - return this.focusState >= MenubarState.FOCUSED; - } - - private get isOpen(): boolean { - return this.focusState >= MenubarState.OPEN; - } - - private get hasOverflow(): boolean { - return this.numMenusShown < this.menuCache.length; - } - - private setUnfocusedState(): void { - if (this.options.visibility === 'toggle' || this.options.visibility === 'hidden') { - this.focusState = MenubarState.HIDDEN; - } else if (this.options.visibility === 'default' && browser.isFullscreen()) { - this.focusState = MenubarState.HIDDEN; - } else { - this.focusState = MenubarState.VISIBLE; - } - - this.ignoreNextMouseUp = false; - this.mnemonicsInUse = false; - this.updateMnemonicVisibility(false); - } - - private focusPrevious(): void { - - if (!this.focusedMenu) { - return; - } - - - let newFocusedIndex = (this.focusedMenu.index - 1 + this.numMenusShown) % this.numMenusShown; - if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) { - newFocusedIndex = this.numMenusShown - 1; - } else if (this.focusedMenu.index === 0 && this.hasOverflow) { - newFocusedIndex = MenuBar.OVERFLOW_INDEX; - } - - if (newFocusedIndex === this.focusedMenu.index) { - return; - } - - if (this.isOpen) { - this.cleanupCustomMenu(); - this.showCustomMenu(newFocusedIndex); - } else if (this.isFocused) { - this.focusedMenu.index = newFocusedIndex; - if (newFocusedIndex === MenuBar.OVERFLOW_INDEX) { - this.overflowMenu.buttonElement.focus(); - } else { - this.menuCache[newFocusedIndex].buttonElement.focus(); - } - } - } - - private focusNext(): void { - if (!this.focusedMenu) { - return; - } - - let newFocusedIndex = (this.focusedMenu.index + 1) % this.numMenusShown; - if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) { - newFocusedIndex = 0; - } else if (this.focusedMenu.index === this.numMenusShown - 1) { - newFocusedIndex = MenuBar.OVERFLOW_INDEX; - } - - if (newFocusedIndex === this.focusedMenu.index) { - return; - } - - if (this.isOpen) { - this.cleanupCustomMenu(); - this.showCustomMenu(newFocusedIndex); - } else if (this.isFocused) { - this.focusedMenu.index = newFocusedIndex; - if (newFocusedIndex === MenuBar.OVERFLOW_INDEX) { - this.overflowMenu.buttonElement.focus(); - } else { - this.menuCache[newFocusedIndex].buttonElement.focus(); - } - } - } - - private updateMnemonicVisibility(visible: boolean): void { - if (this.menuCache) { - this.menuCache.forEach(menuBarMenu => { - if (menuBarMenu.titleElement.children.length) { - let child = menuBarMenu.titleElement.children.item(0) as HTMLElement; - if (child) { - child.style.textDecoration = visible ? 'underline' : null; - } - } - }); - } - } - - private get mnemonicsInUse(): boolean { - return this._mnemonicsInUse; - } - - private set mnemonicsInUse(value: boolean) { - this._mnemonicsInUse = value; - } - - public get onVisibilityChange(): Event { - return this._onVisibilityChange.event; - } - - public get onFocusStateChange(): Event { - return this._onFocusStateChange.event; - } - - private onMenuTriggered(menuIndex: number, clicked: boolean) { - if (this.isOpen) { - if (this.isCurrentMenu(menuIndex)) { - this.setUnfocusedState(); - } else { - this.cleanupCustomMenu(); - this.showCustomMenu(menuIndex, this.openedViaKeyboard); - } - } else { - this.focusedMenu = { index: menuIndex }; - this.openedViaKeyboard = !clicked; - this.focusState = MenubarState.OPEN; - } - } - - private onModifierKeyToggled(modifierKeyStatus: IModifierKeyStatus): void { - const allModifiersReleased = !modifierKeyStatus.altKey && !modifierKeyStatus.ctrlKey && !modifierKeyStatus.shiftKey; - - if (this.options.visibility === 'hidden') { - return; - } - - // Alt key pressed while menu is focused. This should return focus away from the menubar - if (this.isFocused && modifierKeyStatus.lastKeyPressed === 'alt' && modifierKeyStatus.altKey) { - this.setUnfocusedState(); - this.mnemonicsInUse = false; - this.awaitingAltRelease = true; - } - - // Clean alt key press and release - if (allModifiersReleased && modifierKeyStatus.lastKeyPressed === 'alt' && modifierKeyStatus.lastKeyReleased === 'alt') { - if (!this.awaitingAltRelease) { - if (!this.isFocused) { - this.mnemonicsInUse = true; - this.focusedMenu = { index: this.numMenusShown > 0 ? 0 : MenuBar.OVERFLOW_INDEX }; - this.focusState = MenubarState.FOCUSED; - } else if (!this.isOpen) { - this.setUnfocusedState(); - } - } - } - - // Alt key released - if (!modifierKeyStatus.altKey && modifierKeyStatus.lastKeyReleased === 'alt') { - this.awaitingAltRelease = false; - } - - if (this.options.enableMnemonics && this.menuCache && !this.isOpen) { - this.updateMnemonicVisibility((!this.awaitingAltRelease && modifierKeyStatus.altKey) || this.mnemonicsInUse); - } - } - - private isCurrentMenu(menuIndex: number): boolean { - if (!this.focusedMenu) { - return false; - } - - return this.focusedMenu.index === menuIndex; - } - - private cleanupCustomMenu(): void { - if (this.focusedMenu) { - // Remove focus from the menus first - if (this.focusedMenu.index === MenuBar.OVERFLOW_INDEX) { - this.overflowMenu.buttonElement.focus(); - } else { - this.menuCache[this.focusedMenu.index].buttonElement.focus(); - } - - if (this.focusedMenu.holder) { - DOM.removeClass(this.focusedMenu.holder.parentElement, 'open'); - this.focusedMenu.holder.remove(); - } - - if (this.focusedMenu.widget) { - this.focusedMenu.widget.dispose(); - } - - this.focusedMenu = { index: this.focusedMenu.index }; - } - } - - private showCustomMenu(menuIndex: number, selectFirst = true): void { - const actualMenuIndex = menuIndex >= this.numMenusShown ? MenuBar.OVERFLOW_INDEX : menuIndex; - const customMenu = actualMenuIndex === MenuBar.OVERFLOW_INDEX ? this.overflowMenu : this.menuCache[actualMenuIndex]; - const menuHolder = $('div.menubar-menu-items-holder'); - - DOM.addClass(customMenu.buttonElement, 'open'); - menuHolder.style.top = `${this.container.clientHeight}px`; - menuHolder.style.left = `${customMenu.buttonElement.getBoundingClientRect().left}px`; - - customMenu.buttonElement.appendChild(menuHolder); - - let menuOptions: IMenuOptions = { - getKeyBinding: this.options.getKeybinding, - actionRunner: this.actionRunner, - enableMnemonics: this.mnemonicsInUse && this.options.enableMnemonics, - ariaLabel: customMenu.buttonElement.attributes['aria-label'].value - }; - - let menuWidget = this._register(new Menu(menuHolder, customMenu.actions, menuOptions)); - menuWidget.style(this.menuStyle); - - this._register(menuWidget.onDidCancel(() => { - this.focusState = MenubarState.FOCUSED; - })); - - this._register(menuWidget.onDidBlur(() => { - setTimeout(() => { - this.cleanupCustomMenu(); - }, 100); - })); - - if (actualMenuIndex !== menuIndex) { - menuWidget.trigger(menuIndex - this.numMenusShown); - } else { - menuWidget.focus(selectFirst); - } - - this.focusedMenu = { - index: actualMenuIndex, - holder: menuHolder, - widget: menuWidget - }; - } -} - -type ModifierKey = 'alt' | 'ctrl' | 'shift'; - -interface IModifierKeyStatus { - altKey: boolean; - shiftKey: boolean; - ctrlKey: boolean; - lastKeyPressed?: ModifierKey; - lastKeyReleased?: ModifierKey; -} - - -class ModifierKeyEmitter extends Emitter { - - private _subscriptions: IDisposable[] = []; - private _keyStatus: IModifierKeyStatus; - private static instance: ModifierKeyEmitter; - - private constructor() { - super(); - - this._keyStatus = { - altKey: false, - shiftKey: false, - ctrlKey: false - }; - - this._subscriptions.push(domEvent(document.body, 'keydown')(e => { - const event = new StandardKeyboardEvent(e); - - if (e.altKey && !this._keyStatus.altKey) { - this._keyStatus.lastKeyPressed = 'alt'; - } else if (e.ctrlKey && !this._keyStatus.ctrlKey) { - this._keyStatus.lastKeyPressed = 'ctrl'; - } else if (e.shiftKey && !this._keyStatus.shiftKey) { - this._keyStatus.lastKeyPressed = 'shift'; - } else if (event.keyCode !== KeyCode.Alt) { - this._keyStatus.lastKeyPressed = undefined; - } else { - return; - } - - this._keyStatus.altKey = e.altKey; - this._keyStatus.ctrlKey = e.ctrlKey; - this._keyStatus.shiftKey = e.shiftKey; - - if (this._keyStatus.lastKeyPressed) { - this.fire(this._keyStatus); - } - })); - this._subscriptions.push(domEvent(document.body, 'keyup')(e => { - if (!e.altKey && this._keyStatus.altKey) { - this._keyStatus.lastKeyReleased = 'alt'; - } else if (!e.ctrlKey && this._keyStatus.ctrlKey) { - this._keyStatus.lastKeyReleased = 'ctrl'; - } else if (!e.shiftKey && this._keyStatus.shiftKey) { - this._keyStatus.lastKeyReleased = 'shift'; - } else { - this._keyStatus.lastKeyReleased = undefined; - } - - if (this._keyStatus.lastKeyPressed !== this._keyStatus.lastKeyReleased) { - this._keyStatus.lastKeyPressed = undefined; - } - - this._keyStatus.altKey = e.altKey; - this._keyStatus.ctrlKey = e.ctrlKey; - this._keyStatus.shiftKey = e.shiftKey; - - if (this._keyStatus.lastKeyReleased) { - this.fire(this._keyStatus); - } - })); - this._subscriptions.push(domEvent(document.body, 'mousedown')(e => { - this._keyStatus.lastKeyPressed = undefined; - })); - - - this._subscriptions.push(domEvent(window, 'blur')(e => { - this._keyStatus.lastKeyPressed = undefined; - this._keyStatus.lastKeyReleased = undefined; - this._keyStatus.altKey = false; - this._keyStatus.shiftKey = false; - this._keyStatus.shiftKey = false; - - this.fire(this._keyStatus); - })); - } - - static getInstance() { - if (!ModifierKeyEmitter.instance) { - ModifierKeyEmitter.instance = new ModifierKeyEmitter(); - } - - return ModifierKeyEmitter.instance; - } - - dispose() { - super.dispose(); - this._subscriptions = dispose(this._subscriptions); - } -} \ No newline at end of file diff --git a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css index 6a0f061d4dc..dad60fe5ab3 100644 --- a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css +++ b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css @@ -156,4 +156,49 @@ .monaco-workbench > .part.titlebar > .window-controls-container .window-icon.window-close:hover { background-color: white; +} + +/* Menubar styles */ + +.monaco-workbench .menubar { + display: flex; + flex-shrink: 1; + box-sizing: border-box; + height: 30px; + -webkit-app-region: no-drag; + overflow: hidden; + flex-wrap: wrap; +} + +.monaco-workbench.fullscreen .menubar { + margin: 0px; + padding: 0px 5px; +} + +.monaco-workbench .menubar > .menubar-menu-button { + align-items: center; + box-sizing: border-box; + padding: 0px 8px; + cursor: default; + -webkit-app-region: no-drag; + zoom: 1; + white-space: nowrap; + outline: 0; +} + +.monaco-workbench .menubar .menubar-menu-items-holder { + position: absolute; + left: 0px; + opacity: 1; + z-index: 2000; +} + +.monaco-workbench .menubar .menubar-menu-items-holder.monaco-menu-container { + font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "HelveticaNeue-Light", "Ubuntu", "Droid Sans", sans-serif; + outline: 0; + border: none; +} + +.monaco-workbench .menubar .menubar-menu-items-holder.monaco-menu-container :focus { + outline: 0; } \ No newline at end of file diff --git a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts index a44177c55c0..8363e7a9057 100644 --- a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts @@ -4,19 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from 'vs/nls'; +import * as browser from 'vs/base/browser/browser'; +import * as strings from 'vs/base/common/strings'; import { IMenubarMenu, IMenubarMenuItemAction, IMenubarMenuItemSubmenu, IMenubarKeybinding, IMenubarService, IMenubarData } from 'vs/platform/menubar/common/menubar'; import { IMenuService, MenuId, IMenu, SubmenuItemAction } from 'vs/platform/actions/common/actions'; import { registerThemingParticipant, ITheme, ICssStyleCollector, IThemeService } from 'vs/platform/theme/common/themeService'; import { IWindowService, MenuBarVisibility, IWindowsService, getTitleBarStyle } from 'vs/platform/windows/common/windows'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { IAction, Action } from 'vs/base/common/actions'; +import { ActionRunner, IActionRunner, IAction, Action } from 'vs/base/common/actions'; import { Separator } from 'vs/base/browser/ui/actionbar/actionbar'; import * as DOM from 'vs/base/browser/dom'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { isMacintosh, isLinux } from 'vs/base/common/platform'; +import { Menu, IMenuOptions, SubmenuAction, MENU_MNEMONIC_REGEX, cleanMnemonic, MENU_ESCAPED_MNEMONIC_REGEX } from 'vs/base/browser/ui/menu/menu'; +import { KeyCode, KeyCodeUtils } from 'vs/base/common/keyCodes'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration'; import { Event, Emitter } from 'vs/base/common/event'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { IDisposable, Disposable, dispose } from 'vs/base/common/lifecycle'; +import { domEvent } from 'vs/base/browser/event'; import { IRecentlyOpened } from 'vs/platform/history/common/history'; import { IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; import { RunOnceScheduler } from 'vs/base/common/async'; @@ -24,13 +30,28 @@ import { MENUBAR_SELECTION_FOREGROUND, MENUBAR_SELECTION_BACKGROUND, MENUBAR_SEL import { URI } from 'vs/base/common/uri'; import { ILabelService } from 'vs/platform/label/common/label'; import { IUpdateService, StateType } from 'vs/platform/update/common/update'; +import { Gesture, EventType, GestureEvent } from 'vs/base/browser/touch'; +import { attachMenuStyler } from 'vs/platform/theme/common/styler'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; -import { MenuBar } from 'vs/base/browser/ui/menu/menubar'; -import { SubmenuAction } from 'vs/base/browser/ui/menu/menu'; -import { attachMenuStyler } from 'vs/platform/theme/common/styler'; + +const $ = DOM.$; + +interface CustomMenu { + title: string; + buttonElement: HTMLElement; + titleElement: HTMLElement; + actions?: IAction[]; +} + +enum MenubarState { + HIDDEN, + VISIBLE, + FOCUSED, + OPEN +} export class MenubarControl extends Disposable { @@ -69,10 +90,28 @@ export class MenubarControl extends Disposable { 'Help': nls.localize({ key: 'mHelp', comment: ['&& denotes a mnemonic'] }, "&&Help") }; - private menubar: MenuBar; + private focusedMenu: { + index: number; + holder?: HTMLElement; + widget?: Menu; + }; + + private customMenus: CustomMenu[]; + private menuUpdater: RunOnceScheduler; + private actionRunner: IActionRunner; + private focusToReturn: HTMLElement; private container: HTMLElement; private recentlyOpened: IRecentlyOpened; + private updatePending: boolean; + private _focusState: MenubarState; + + // Input-related + private _mnemonicsInUse: boolean; + private openedViaKeyboard: boolean; + private awaitingAltRelease: boolean; + private ignoreNextMouseUp: boolean; + private mnemonics: Map; private _onVisibilityChange: Emitter; private _onFocusStateChange: Emitter; @@ -113,19 +152,25 @@ export class MenubarControl extends Disposable { this.topLevelMenus['Preferences'] = this._register(this.menuService.createMenu(MenuId.MenubarPreferencesMenu, this.contextKeyService)); } - this.menuUpdater = this._register(new RunOnceScheduler(() => this.doUpdateMenubar(false), 200)); + this.menuUpdater = this._register(new RunOnceScheduler(() => this.doSetupMenubar(), 200)); - if (isMacintosh || this.currentTitlebarStyleSetting !== 'custom') { - for (let topLevelMenuName of Object.keys(this.topLevelMenus)) { - this._register(this.topLevelMenus[topLevelMenuName].onDidChange(() => this.updateMenubar())); - } - - this.doUpdateMenubar(true); - } + this.actionRunner = this._register(new ActionRunner()); + this._register(this.actionRunner.onDidBeforeRun(() => { + this.setUnfocusedState(); + })); this._onVisibilityChange = this._register(new Emitter()); this._onFocusStateChange = this._register(new Emitter()); + if (isMacintosh || this.currentTitlebarStyleSetting !== 'custom') { + for (let topLevelMenuName of Object.keys(this.topLevelMenus)) { + this._register(this.topLevelMenus[topLevelMenuName].onDidChange(() => this.setupMenubar())); + } + this.doSetupMenubar(); + } + + this._focusState = MenubarState.HIDDEN; + this.windowService.getRecentlyOpened().then((recentlyOpened) => { this.recentlyOpened = recentlyOpened; }); @@ -174,31 +219,231 @@ export class MenubarControl extends Disposable { return getTitleBarStyle(this.configurationService, this.environmentService); } + private get focusState(): MenubarState { + return this._focusState; + } + + private set focusState(value: MenubarState) { + if (this._focusState >= MenubarState.FOCUSED && value < MenubarState.FOCUSED) { + // Losing focus, update the menu if needed + + if (this.updatePending) { + this.menuUpdater.schedule(); + this.updatePending = false; + } + } + + if (value === this._focusState) { + return; + } + + const isVisible = this.isVisible; + const isOpen = this.isOpen; + const isFocused = this.isFocused; + + this._focusState = value; + + switch (value) { + case MenubarState.HIDDEN: + if (isVisible) { + this.hideMenubar(); + } + + if (isOpen) { + this.cleanupCustomMenu(); + } + + if (isFocused) { + this.focusedMenu = null; + + if (this.focusToReturn) { + this.focusToReturn.focus(); + this.focusToReturn = null; + } + } + + + break; + case MenubarState.VISIBLE: + if (!isVisible) { + this.showMenubar(); + } + + if (isOpen) { + this.cleanupCustomMenu(); + } + + if (isFocused) { + if (this.focusedMenu) { + this.customMenus[this.focusedMenu.index].buttonElement.blur(); + } + + this.focusedMenu = null; + + if (this.focusToReturn) { + this.focusToReturn.focus(); + this.focusToReturn = null; + } + } + + break; + case MenubarState.FOCUSED: + if (!isVisible) { + this.showMenubar(); + } + + if (isOpen) { + this.cleanupCustomMenu(); + } + + if (this.focusedMenu) { + this.customMenus[this.focusedMenu.index].buttonElement.focus(); + } + break; + case MenubarState.OPEN: + if (!isVisible) { + this.showMenubar(); + } + + if (this.focusedMenu) { + this.showCustomMenu(this.focusedMenu.index, this.openedViaKeyboard); + } + break; + } + + this._focusState = value; + this._onFocusStateChange.fire(this.focusState >= MenubarState.FOCUSED); + } + + private get mnemonicsInUse(): boolean { + return this._mnemonicsInUse; + } + + private set mnemonicsInUse(value: boolean) { + this._mnemonicsInUse = value; + } + + private get isVisible(): boolean { + return this.focusState >= MenubarState.VISIBLE; + } + + private get isFocused(): boolean { + return this.focusState >= MenubarState.FOCUSED; + } + + private get isOpen(): boolean { + return this.focusState >= MenubarState.OPEN; + } + + private onDidChangeFullscreen(): void { + this.setUnfocusedState(); + } + private onDidChangeWindowFocus(hasFocus: boolean): void { if (this.container) { if (hasFocus) { DOM.removeClass(this.container, 'inactive'); } else { DOM.addClass(this.container, 'inactive'); - this.menubar.blur(); + this.setUnfocusedState(); + this.awaitingAltRelease = false; } } } private onConfigurationUpdated(event: IConfigurationChangeEvent): void { if (this.keys.some(key => event.affectsConfiguration(key))) { - this.updateMenubar(); + this.setupMenubar(); } if (event.affectsConfiguration('window.menuBarVisibility')) { + this.setUnfocusedState(); this.detectAndRecommendCustomTitlebar(); } } + private setUnfocusedState(): void { + if (this.currentMenubarVisibility === 'toggle' || this.currentMenubarVisibility === 'hidden') { + this.focusState = MenubarState.HIDDEN; + } else if (this.currentMenubarVisibility === 'default' && browser.isFullscreen()) { + this.focusState = MenubarState.HIDDEN; + } else { + this.focusState = MenubarState.VISIBLE; + } + + this.ignoreNextMouseUp = false; + this.mnemonicsInUse = false; + this.updateMnemonicVisibility(false); + } + + private hideMenubar(): void { + if (this.container.style.display !== 'none') { + this.container.style.display = 'none'; + this._onVisibilityChange.fire(false); + } + } + + private showMenubar(): void { + if (this.container.style.display !== 'flex') { + this.container.style.display = 'flex'; + this._onVisibilityChange.fire(true); + } + } + + private onModifierKeyToggled(modifierKeyStatus: IModifierKeyStatus): void { + const allModifiersReleased = !modifierKeyStatus.altKey && !modifierKeyStatus.ctrlKey && !modifierKeyStatus.shiftKey; + + if (this.currentMenubarVisibility === 'hidden') { + return; + } + + // Alt key pressed while menu is focused. This should return focus away from the menubar + if (this.isFocused && modifierKeyStatus.lastKeyPressed === 'alt' && modifierKeyStatus.altKey) { + this.setUnfocusedState(); + this.mnemonicsInUse = false; + this.awaitingAltRelease = true; + } + + // Clean alt key press and release + if (allModifiersReleased && modifierKeyStatus.lastKeyPressed === 'alt' && modifierKeyStatus.lastKeyReleased === 'alt') { + if (!this.awaitingAltRelease) { + if (!this.isFocused) { + this.mnemonicsInUse = true; + this.focusedMenu = { index: 0 }; + this.focusState = MenubarState.FOCUSED; + } else if (!this.isOpen) { + this.setUnfocusedState(); + } + } + } + + // Alt key released + if (!modifierKeyStatus.altKey && modifierKeyStatus.lastKeyReleased === 'alt') { + this.awaitingAltRelease = false; + } + + if (this.currentEnableMenuBarMnemonics && this.customMenus && !this.isOpen) { + this.updateMnemonicVisibility((!this.awaitingAltRelease && modifierKeyStatus.altKey) || this.mnemonicsInUse); + } + } + + private updateMnemonicVisibility(visible: boolean): void { + if (this.customMenus) { + this.customMenus.forEach(customMenu => { + if (customMenu.titleElement.children.length) { + let child = customMenu.titleElement.children.item(0) as HTMLElement; + if (child) { + child.style.textDecoration = visible ? 'underline' : null; + } + } + }); + } + } + private onRecentlyOpenedChange(): void { this.windowService.getRecentlyOpened().then(recentlyOpened => { this.recentlyOpened = recentlyOpened; - this.updateMenubar(); + this.setupMenubar(); }); } @@ -242,30 +487,30 @@ export class MenubarControl extends Disposable { this._register(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(e))); // Listen to update service - this.updateService.onStateChange(() => this.updateMenubar()); + this.updateService.onStateChange(() => this.setupMenubar()); // Listen for changes in recently opened menu this._register(this.windowsService.onRecentlyOpenedChange(() => { this.onRecentlyOpenedChange(); })); // Listen to keybindings change - this._register(this.keybindingService.onDidUpdateKeybindings(() => this.updateMenubar())); + this._register(this.keybindingService.onDidUpdateKeybindings(() => this.setupMenubar())); // These listeners only apply when the custom menubar is being used if (!isMacintosh && this.currentTitlebarStyleSetting === 'custom') { + // Listen to fullscreen changes + this._register(browser.onDidChangeFullscreen(() => this.onDidChangeFullscreen())); + + // Listen for alt key presses + this._register(ModifierKeyEmitter.getInstance(this.windowService).event(this.onModifierKeyToggled, this)); + // Listen for window focus changes this._register(this.windowService.onDidChangeFocus(e => this.onDidChangeWindowFocus(e))); - - this._register(this.windowService.onDidChangeMaximize(e => this.updateMenubar())); - - this._register(DOM.addDisposableListener(window, DOM.EventType.RESIZE, () => { - this.menubar.blur(); - })); } } - private doUpdateMenubar(firstTime: boolean): void { + private doSetupMenubar(): void { if (!isMacintosh && this.currentTitlebarStyleSetting === 'custom') { - this.setupCustomMenubar(firstTime); + this.setupCustomMenubar(); } else { // Send menus to main process to be rendered by Electron const menubarData = { menus: {}, keybindings: {} }; @@ -275,10 +520,14 @@ export class MenubarControl extends Disposable { } } - private updateMenubar(): void { + private setupMenubar(): void { this.menuUpdater.schedule(); } + private registerMnemonic(menuIndex: number, mnemonic: string): void { + this.mnemonics.set(KeyCodeUtils.fromString(mnemonic), menuIndex); + } + private calculateActionLabel(action: IAction | IMenubarMenuItemAction): string { let label = action.label; switch (action.id) { @@ -424,69 +673,301 @@ export class MenubarControl extends Disposable { } } - private setupCustomMenubar(firstTime: boolean): void { - if (firstTime) { - this.menubar = this._register(new MenuBar( - this.container, { - enableMnemonics: this.currentEnableMenuBarMnemonics, - visibility: this.currentMenubarVisibility, - getKeybinding: (action) => this.keybindingService.lookupKeybinding(action.id), - } - )); - - this._register(this.menubar.onFocusStateChange(e => this._onFocusStateChange.fire(e))); - this._register(this.menubar.onVisibilityChange(e => this._onVisibilityChange.fire(e))); - - this._register(attachMenuStyler(this.menubar, this.themeService)); - } else { - this.menubar.update({ enableMnemonics: this.currentEnableMenuBarMnemonics, visibility: this.currentMenubarVisibility, getKeybinding: (action) => this.keybindingService.lookupKeybinding(action.id) }); + private setupCustomMenubar(): void { + // Don't update while using the menu + if (this.isFocused) { + this.updatePending = true; + return; } - // Update the menu actions - const updateActions = (menu: IMenu, target: IAction[]) => { - target.splice(0); - let groups = menu.getActions(); - for (let group of groups) { - const [, actions] = group; + this.container.attributes['role'] = 'menubar'; - for (let action of actions) { - this.insertActionsBefore(action, target); - if (action instanceof SubmenuItemAction) { - const submenu = this.menuService.createMenu(action.item.submenu, this.contextKeyService); - const submenuActions: SubmenuAction[] = []; - updateActions(submenu, submenuActions); - target.push(new SubmenuAction(action.label, submenuActions)); - submenu.dispose(); - } else { - action.label = this.calculateActionLabel(action); - target.push(action); - } + const firstTimeSetup = this.customMenus === undefined; + if (firstTimeSetup) { + this.customMenus = []; + this.mnemonics = new Map(); + } + + let idx = 0; + + for (let menuTitle of Object.keys(this.topLevelMenus)) { + const menu: IMenu = this.topLevelMenus[menuTitle]; + let menuIndex = idx++; + const cleanMenuLabel = cleanMnemonic(this.topLevelTitles[menuTitle]); + + // Create the top level menu button element + if (firstTimeSetup) { + + const buttonElement = $('div.menubar-menu-button', { 'role': 'menuitem', 'tabindex': -1, 'aria-label': cleanMenuLabel, 'aria-haspopup': true }); + const titleElement = $('div.menubar-menu-title', { 'role': 'none', 'aria-hidden': true }); + + buttonElement.appendChild(titleElement); + this.container.appendChild(buttonElement); + + this.customMenus.push({ + title: menuTitle, + buttonElement: buttonElement, + titleElement: titleElement + }); + } + + // Update the button label to reflect mnemonics + this.customMenus[menuIndex].titleElement.innerHTML = this.currentEnableMenuBarMnemonics ? + strings.escape(this.topLevelTitles[menuTitle]).replace(MENU_ESCAPED_MNEMONIC_REGEX, '') : + cleanMenuLabel; + + let mnemonicMatches = MENU_MNEMONIC_REGEX.exec(this.topLevelTitles[menuTitle]); + + // Register mnemonics + if (mnemonicMatches) { + let mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[2]; + + if (firstTimeSetup) { + this.registerMnemonic(menuIndex, mnemonic); } - target.push(new Separator()); + if (this.currentEnableMenuBarMnemonics) { + this.customMenus[menuIndex].buttonElement.setAttribute('aria-keyshortcuts', 'Alt+' + mnemonic.toLocaleLowerCase()); + } else { + this.customMenus[menuIndex].buttonElement.removeAttribute('aria-keyshortcuts'); + } } - target.pop(); - }; + // Update the menu actions + const updateActions = (menu: IMenu, target: IAction[]) => { + target.splice(0); + let groups = menu.getActions(); + for (let group of groups) { + const [, actions] = group; - for (let title of Object.keys(this.topLevelMenus)) { - const menu = this.topLevelMenus[title]; - if (firstTime) { - this._register(menu.onDidChange(() => { - const actions = []; - updateActions(menu, actions); - this.menubar.updateMenu({ actions: actions, label: this.topLevelTitles[title] }); + for (let action of actions) { + this.insertActionsBefore(action, target); + if (action instanceof SubmenuItemAction) { + const submenu = this.menuService.createMenu(action.item.submenu, this.contextKeyService); + const submenuActions: SubmenuAction[] = []; + updateActions(submenu, submenuActions); + target.push(new SubmenuAction(action.label, submenuActions)); + submenu.dispose(); + } else { + action.label = this.calculateActionLabel(action); + target.push(action); + } + } + + target.push(new Separator()); + } + + target.pop(); + }; + + this.customMenus[menuIndex].actions = []; + if (firstTimeSetup) { + this._register(menu.onDidChange(() => updateActions(menu, this.customMenus[menuIndex].actions))); + } + + updateActions(menu, this.customMenus[menuIndex].actions); + + if (firstTimeSetup) { + this._register(DOM.addDisposableListener(this.customMenus[menuIndex].buttonElement, DOM.EventType.KEY_UP, (e) => { + let event = new StandardKeyboardEvent(e); + let eventHandled = true; + + if ((event.equals(KeyCode.DownArrow) || event.equals(KeyCode.Enter)) && !this.isOpen) { + this.focusedMenu = { index: menuIndex }; + this.openedViaKeyboard = true; + this.focusState = MenubarState.OPEN; + } else { + eventHandled = false; + } + + if (eventHandled) { + event.preventDefault(); + event.stopPropagation(); + } + })); + + Gesture.addTarget(this.customMenus[menuIndex].buttonElement); + this._register(DOM.addDisposableListener(this.customMenus[menuIndex].buttonElement, EventType.Tap, (e: GestureEvent) => { + // Ignore this touch if the menu is touched + if (this.isOpen && this.focusedMenu.holder && DOM.isAncestor(e.initialTarget as HTMLElement, this.focusedMenu.holder)) { + return; + } + + this.ignoreNextMouseUp = false; + this.onMenuTriggered(menuIndex, true); + + e.preventDefault(); + e.stopPropagation(); + })); + + this._register(DOM.addDisposableListener(this.customMenus[menuIndex].buttonElement, DOM.EventType.MOUSE_DOWN, (e) => { + if (!this.isOpen) { + // Open the menu with mouse down and ignore the following mouse up event + this.ignoreNextMouseUp = true; + this.onMenuTriggered(menuIndex, true); + } else { + this.ignoreNextMouseUp = false; + } + + e.preventDefault(); + e.stopPropagation(); + })); + + this._register(DOM.addDisposableListener(this.customMenus[menuIndex].buttonElement, DOM.EventType.MOUSE_UP, (e) => { + if (!this.ignoreNextMouseUp) { + if (this.isFocused) { + this.onMenuTriggered(menuIndex, true); + } + } else { + this.ignoreNextMouseUp = false; + } + })); + + this._register(DOM.addDisposableListener(this.customMenus[menuIndex].buttonElement, DOM.EventType.MOUSE_ENTER, () => { + if (this.isOpen && !this.isCurrentMenu(menuIndex)) { + this.customMenus[menuIndex].buttonElement.focus(); + this.cleanupCustomMenu(); + this.showCustomMenu(menuIndex, false); + } else if (this.isFocused && !this.isOpen) { + this.focusedMenu = { index: menuIndex }; + this.customMenus[menuIndex].buttonElement.focus(); + } })); } + } - const actions = []; - updateActions(menu, actions); + if (firstTimeSetup) { + this._register(DOM.addDisposableListener(this.container, DOM.EventType.KEY_DOWN, (e) => { + let event = new StandardKeyboardEvent(e); + let eventHandled = true; + const key = !!e.key ? KeyCodeUtils.fromString(e.key) : KeyCode.Unknown; - if (!firstTime) { - this.menubar.updateMenu({ actions: actions, label: this.topLevelTitles[title] }); + if (event.equals(KeyCode.LeftArrow)) { + this.focusPrevious(); + } else if (event.equals(KeyCode.RightArrow)) { + this.focusNext(); + } else if (event.equals(KeyCode.Escape) && this.isFocused && !this.isOpen) { + this.setUnfocusedState(); + } else if (!this.isOpen && !event.ctrlKey && this.currentEnableMenuBarMnemonics && this.mnemonicsInUse && this.mnemonics.has(key)) { + const menuIndex = this.mnemonics.get(key); + this.onMenuTriggered(menuIndex, false); + } else { + eventHandled = false; + } + + if (eventHandled) { + event.preventDefault(); + event.stopPropagation(); + } + })); + + this._register(DOM.addDisposableListener(window, DOM.EventType.MOUSE_DOWN, () => { + // This mouse event is outside the menubar so it counts as a focus out + if (this.isFocused) { + this.setUnfocusedState(); + } + })); + + this._register(DOM.addDisposableListener(this.container, DOM.EventType.FOCUS_IN, (e) => { + let event = e as FocusEvent; + + if (event.relatedTarget) { + if (!this.container.contains(event.relatedTarget as HTMLElement)) { + this.focusToReturn = event.relatedTarget as HTMLElement; + } + } + })); + + this._register(DOM.addDisposableListener(this.container, DOM.EventType.FOCUS_OUT, (e) => { + let event = e as FocusEvent; + + if (event.relatedTarget) { + if (!this.container.contains(event.relatedTarget as HTMLElement)) { + this.focusToReturn = null; + this.setUnfocusedState(); + } + } + })); + + this._register(DOM.addDisposableListener(window, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (!this.currentEnableMenuBarMnemonics || !e.altKey || e.ctrlKey || e.defaultPrevented) { + return; + } + + const key = KeyCodeUtils.fromString(e.key); + if (!this.mnemonics.has(key)) { + return; + } + + // Prevent conflicts with keybindings + const standardKeyboardEvent = new StandardKeyboardEvent(e); + const resolvedResult = this.keybindingService.softDispatch(standardKeyboardEvent, standardKeyboardEvent.target); + if (resolvedResult) { + return; + } + + this.mnemonicsInUse = true; + this.updateMnemonicVisibility(true); + + const menuIndex = this.mnemonics.get(key); + this.onMenuTriggered(menuIndex, false); + })); + } + } + + private onMenuTriggered(menuIndex: number, clicked: boolean) { + if (this.isOpen) { + if (this.isCurrentMenu(menuIndex)) { + this.setUnfocusedState(); } else { - this.menubar.push({ actions: actions, label: this.topLevelTitles[title] }); + this.cleanupCustomMenu(); + this.showCustomMenu(menuIndex, this.openedViaKeyboard); } + } else { + this.focusedMenu = { index: menuIndex }; + this.openedViaKeyboard = !clicked; + this.focusState = MenubarState.OPEN; + } + } + + private focusPrevious(): void { + + if (!this.focusedMenu) { + return; + } + + let newFocusedIndex = (this.focusedMenu.index - 1 + this.customMenus.length) % this.customMenus.length; + + if (newFocusedIndex === this.focusedMenu.index) { + return; + } + + if (this.isOpen) { + this.cleanupCustomMenu(); + this.showCustomMenu(newFocusedIndex); + } else if (this.isFocused) { + this.focusedMenu.index = newFocusedIndex; + this.customMenus[newFocusedIndex].buttonElement.focus(); + } + } + + private focusNext(): void { + if (!this.focusedMenu) { + return; + } + + let newFocusedIndex = (this.focusedMenu.index + 1) % this.customMenus.length; + + if (newFocusedIndex === this.focusedMenu.index) { + return; + } + + if (this.isOpen) { + this.cleanupCustomMenu(); + this.showCustomMenu(newFocusedIndex); + } else if (this.isFocused) { + this.focusedMenu.index = newFocusedIndex; + this.customMenus[newFocusedIndex].buttonElement.focus(); } } @@ -588,6 +1069,71 @@ export class MenubarControl extends Disposable { return true; } + private isCurrentMenu(menuIndex: number): boolean { + if (!this.focusedMenu) { + return false; + } + + return this.focusedMenu.index === menuIndex; + } + + private cleanupCustomMenu(): void { + if (this.focusedMenu) { + // Remove focus from the menus first + this.customMenus[this.focusedMenu.index].buttonElement.focus(); + + if (this.focusedMenu.holder) { + DOM.removeClass(this.focusedMenu.holder.parentElement, 'open'); + this.focusedMenu.holder.remove(); + } + + if (this.focusedMenu.widget) { + this.focusedMenu.widget.dispose(); + } + + this.focusedMenu = { index: this.focusedMenu.index }; + } + } + + private showCustomMenu(menuIndex: number, selectFirst = true): void { + const customMenu = this.customMenus[menuIndex]; + const menuHolder = $('div.menubar-menu-items-holder'); + + DOM.addClass(customMenu.buttonElement, 'open'); + menuHolder.style.top = `${this.container.clientHeight}px`; + menuHolder.style.left = `${customMenu.buttonElement.getBoundingClientRect().left}px`; + + customMenu.buttonElement.appendChild(menuHolder); + + let menuOptions: IMenuOptions = { + getKeyBinding: (action) => this.keybindingService.lookupKeybinding(action.id), + actionRunner: this.actionRunner, + enableMnemonics: this.mnemonicsInUse && this.currentEnableMenuBarMnemonics, + ariaLabel: customMenu.buttonElement.attributes['aria-label'].value + }; + + let menuWidget = this._register(new Menu(menuHolder, customMenu.actions, menuOptions)); + this._register(attachMenuStyler(menuWidget, this.themeService)); + + this._register(menuWidget.onDidCancel(() => { + this.focusState = MenubarState.FOCUSED; + })); + + this._register(menuWidget.onDidBlur(() => { + setTimeout(() => { + this.cleanupCustomMenu(); + }, 100); + })); + + menuWidget.focus(selectFirst); + + this.focusedMenu = { + index: menuIndex, + holder: menuHolder, + widget: menuWidget + }; + } + public get onVisibilityChange(): Event { return this._onVisibilityChange.event; } @@ -601,11 +1147,21 @@ export class MenubarControl extends Disposable { this.container.style.height = `${dimension.height}px`; } - this.menubar.update({ enableMnemonics: this.currentEnableMenuBarMnemonics, visibility: this.currentMenubarVisibility, getKeybinding: (action) => this.keybindingService.lookupKeybinding(action.id) }); + if (!this.isVisible) { + this.hideMenubar(); + } else { + this.showMenubar(); + } } public getMenubarItemsDimensions(): DOM.Dimension { - return new DOM.Dimension(this.menubar.getWidth(), this.menubar.getHeight()); + if (this.customMenus) { + const left = this.customMenus[0].buttonElement.getBoundingClientRect().left; + const right = this.customMenus[this.customMenus.length - 1].buttonElement.getBoundingClientRect().right; + return new DOM.Dimension(right - left, this.container.clientHeight); + } + + return new DOM.Dimension(0, 0); } public create(parent: HTMLElement): HTMLElement { @@ -613,9 +1169,10 @@ export class MenubarControl extends Disposable { // Build the menubar if (this.container) { + this.doSetupMenubar(); if (!isMacintosh && this.currentTitlebarStyleSetting === 'custom') { - this.doUpdateMenubar(true); + this.setUnfocusedState(); } } @@ -630,10 +1187,6 @@ registerThemingParticipant((theme: ITheme, collector: ICssStyleCollector) => { .monaco-workbench .menubar > .menubar-menu-button { color: ${menubarActiveWindowFgColor}; } - - .monaco-workbench .menubar .toolbar-toggle-more { - background-color: ${menubarActiveWindowFgColor} - } `); } @@ -643,10 +1196,6 @@ registerThemingParticipant((theme: ITheme, collector: ICssStyleCollector) => { .monaco-workbench .menubar.inactive > .menubar-menu-button { color: ${menubarInactiveWindowFgColor}; } - - .monaco-workbench .menubar.inactive > .menubar-menu-button .toolbar-toggle-more { - background-color: ${menubarInactiveWindowFgColor} - } `); } @@ -659,12 +1208,6 @@ registerThemingParticipant((theme: ITheme, collector: ICssStyleCollector) => { .monaco-workbench .menubar:not(:focus-within) > .menubar-menu-button:hover { color: ${menubarSelectedFgColor}; } - - .monaco-workbench .menubar > .menubar-menu-button.open .toolbar-toggle-more, - .monaco-workbench .menubar > .menubar-menu-button:focus .toolbar-toggle-more, - .monaco-workbench .menubar:not(:focus-within) > .menubar-menu-button:hover .toolbar-toggle-more { - background-color: ${menubarSelectedFgColor} - } `); } @@ -700,3 +1243,106 @@ registerThemingParticipant((theme: ITheme, collector: ICssStyleCollector) => { `); } }); + +type ModifierKey = 'alt' | 'ctrl' | 'shift'; + +interface IModifierKeyStatus { + altKey: boolean; + shiftKey: boolean; + ctrlKey: boolean; + lastKeyPressed?: ModifierKey; + lastKeyReleased?: ModifierKey; +} + + +class ModifierKeyEmitter extends Emitter { + + private _subscriptions: IDisposable[] = []; + private _keyStatus: IModifierKeyStatus; + private static instance: ModifierKeyEmitter; + + private constructor(windowService: IWindowService) { + super(); + + this._keyStatus = { + altKey: false, + shiftKey: false, + ctrlKey: false + }; + + this._subscriptions.push(domEvent(document.body, 'keydown')(e => { + const event = new StandardKeyboardEvent(e); + + if (e.altKey && !this._keyStatus.altKey) { + this._keyStatus.lastKeyPressed = 'alt'; + } else if (e.ctrlKey && !this._keyStatus.ctrlKey) { + this._keyStatus.lastKeyPressed = 'ctrl'; + } else if (e.shiftKey && !this._keyStatus.shiftKey) { + this._keyStatus.lastKeyPressed = 'shift'; + } else if (event.keyCode !== KeyCode.Alt) { + this._keyStatus.lastKeyPressed = undefined; + } else { + return; + } + + this._keyStatus.altKey = e.altKey; + this._keyStatus.ctrlKey = e.ctrlKey; + this._keyStatus.shiftKey = e.shiftKey; + + if (this._keyStatus.lastKeyPressed) { + this.fire(this._keyStatus); + } + })); + this._subscriptions.push(domEvent(document.body, 'keyup')(e => { + if (!e.altKey && this._keyStatus.altKey) { + this._keyStatus.lastKeyReleased = 'alt'; + } else if (!e.ctrlKey && this._keyStatus.ctrlKey) { + this._keyStatus.lastKeyReleased = 'ctrl'; + } else if (!e.shiftKey && this._keyStatus.shiftKey) { + this._keyStatus.lastKeyReleased = 'shift'; + } else { + this._keyStatus.lastKeyReleased = undefined; + } + + if (this._keyStatus.lastKeyPressed !== this._keyStatus.lastKeyReleased) { + this._keyStatus.lastKeyPressed = undefined; + } + + this._keyStatus.altKey = e.altKey; + this._keyStatus.ctrlKey = e.ctrlKey; + this._keyStatus.shiftKey = e.shiftKey; + + if (this._keyStatus.lastKeyReleased) { + this.fire(this._keyStatus); + } + })); + this._subscriptions.push(domEvent(document.body, 'mousedown')(e => { + this._keyStatus.lastKeyPressed = undefined; + })); + + this._subscriptions.push(windowService.onDidChangeFocus(focused => { + if (!focused) { + this._keyStatus.lastKeyPressed = undefined; + this._keyStatus.lastKeyReleased = undefined; + this._keyStatus.altKey = false; + this._keyStatus.shiftKey = false; + this._keyStatus.shiftKey = false; + + this.fire(this._keyStatus); + } + })); + } + + static getInstance(windowService: IWindowService) { + if (!ModifierKeyEmitter.instance) { + ModifierKeyEmitter.instance = new ModifierKeyEmitter(windowService); + } + + return ModifierKeyEmitter.instance; + } + + dispose() { + super.dispose(); + this._subscriptions = dispose(this._subscriptions); + } +}