diff --git a/src/blink-mind b/src/blink-mind index 80e41c3..ed681ee 160000 --- a/src/blink-mind +++ b/src/blink-mind @@ -1 +1 @@ -Subproject commit 80e41c3b281b769360ec34b6899f277f0a911f64 +Subproject commit ed681ee1c7ee76f4ed18aeb6fd7a418b961f3fb8 diff --git a/src/main/custom-electron-titlebar/browser/browser.ts b/src/main/custom-electron-titlebar/browser/browser.ts new file mode 100644 index 0000000..d01db83 --- /dev/null +++ b/src/main/custom-electron-titlebar/browser/browser.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../common/event'; +import { IDisposable } from '../common/lifecycle'; +import * as platform from '../common/platform'; + +class WindowManager { + + public static readonly INSTANCE = new WindowManager(); + + // --- Zoom Level + private _zoomLevel: number = 0; + private _lastZoomLevelChangeTime: number = 0; + private readonly _onDidChangeZoomLevel = new Emitter(); + + public readonly onDidChangeZoomLevel: Event = this._onDidChangeZoomLevel.event; + public getZoomLevel(): number { + return this._zoomLevel; + } + public getTimeSinceLastZoomLevelChanged(): number { + return Date.now() - this._lastZoomLevelChangeTime; + } + public setZoomLevel(zoomLevel: number, isTrusted: boolean): void { + if (this._zoomLevel === zoomLevel) { + return; + } + + this._zoomLevel = zoomLevel; + // See https://github.com/Microsoft/vscode/issues/26151 + this._lastZoomLevelChangeTime = isTrusted ? 0 : Date.now(); + this._onDidChangeZoomLevel.fire(this._zoomLevel); + } + + // --- Zoom Factor + private _zoomFactor: number = 0; + + public getZoomFactor(): number { + return this._zoomFactor; + } + public setZoomFactor(zoomFactor: number): void { + this._zoomFactor = zoomFactor; + } + + // --- Pixel Ratio + public getPixelRatio(): number { + let ctx = document.createElement('canvas').getContext('2d'); + let dpr = window.devicePixelRatio || 1; + let bsr = (ctx).webkitBackingStorePixelRatio || + (ctx).mozBackingStorePixelRatio || + (ctx).msBackingStorePixelRatio || + (ctx).oBackingStorePixelRatio || + (ctx).backingStorePixelRatio || 1; + return dpr / bsr; + } + + // --- Fullscreen + private _fullscreen: boolean; + private readonly _onDidChangeFullscreen = new Emitter(); + + public readonly onDidChangeFullscreen: Event = this._onDidChangeFullscreen.event; + public setFullscreen(fullscreen: boolean): void { + if (this._fullscreen === fullscreen) { + return; + } + + this._fullscreen = fullscreen; + this._onDidChangeFullscreen.fire(); + } + public isFullscreen(): boolean { + return this._fullscreen; + } + + // --- Accessibility + private _accessibilitySupport = platform.AccessibilitySupport.Unknown; + private readonly _onDidChangeAccessibilitySupport = new Emitter(); + + public readonly onDidChangeAccessibilitySupport: Event = this._onDidChangeAccessibilitySupport.event; + public setAccessibilitySupport(accessibilitySupport: platform.AccessibilitySupport): void { + if (this._accessibilitySupport === accessibilitySupport) { + return; + } + + this._accessibilitySupport = accessibilitySupport; + this._onDidChangeAccessibilitySupport.fire(); + } + public getAccessibilitySupport(): platform.AccessibilitySupport { + return this._accessibilitySupport; + } +} + +/** A zoom index, e.g. 1, 2, 3 */ +export function setZoomLevel(zoomLevel: number, isTrusted: boolean): void { + WindowManager.INSTANCE.setZoomLevel(zoomLevel, isTrusted); +} +export function getZoomLevel(): number { + return WindowManager.INSTANCE.getZoomLevel(); +} +/** Returns the time (in ms) since the zoom level was changed */ +export function getTimeSinceLastZoomLevelChanged(): number { + return WindowManager.INSTANCE.getTimeSinceLastZoomLevelChanged(); +} +export function onDidChangeZoomLevel(callback: (zoomLevel: number) => void): IDisposable { + return WindowManager.INSTANCE.onDidChangeZoomLevel(callback); +} + +/** The zoom scale for an index, e.g. 1, 1.2, 1.4 */ +export function getZoomFactor(): number { + return WindowManager.INSTANCE.getZoomFactor(); +} +export function setZoomFactor(zoomFactor: number): void { + WindowManager.INSTANCE.setZoomFactor(zoomFactor); +} + +export function getPixelRatio(): number { + return WindowManager.INSTANCE.getPixelRatio(); +} + +export function setFullscreen(fullscreen: boolean): void { + WindowManager.INSTANCE.setFullscreen(fullscreen); +} +export function isFullscreen(): boolean { + return WindowManager.INSTANCE.isFullscreen(); +} +export const onDidChangeFullscreen = WindowManager.INSTANCE.onDidChangeFullscreen; + +export function setAccessibilitySupport(accessibilitySupport: platform.AccessibilitySupport): void { + WindowManager.INSTANCE.setAccessibilitySupport(accessibilitySupport); +} +export function getAccessibilitySupport(): platform.AccessibilitySupport { + return WindowManager.INSTANCE.getAccessibilitySupport(); +} +export function onDidChangeAccessibilitySupport(callback: () => void): IDisposable { + return WindowManager.INSTANCE.onDidChangeAccessibilitySupport(callback); +} + +const userAgent = navigator.userAgent; + +export const isIE = (userAgent.indexOf('Trident') >= 0); +export const isEdge = (userAgent.indexOf('Edge/') >= 0); +export const isEdgeOrIE = isIE || isEdge; + +export const isOpera = (userAgent.indexOf('Opera') >= 0); +export const isFirefox = (userAgent.indexOf('Firefox') >= 0); +export const isWebKit = (userAgent.indexOf('AppleWebKit') >= 0); +export const isChrome = (userAgent.indexOf('Chrome') >= 0); +export const isSafari = (!isChrome && (userAgent.indexOf('Safari') >= 0)); +export const isWebkitWebView = (!isChrome && !isSafari && isWebKit); +export const isIPad = (userAgent.indexOf('iPad') >= 0); +export const isEdgeWebView = isEdge && (userAgent.indexOf('WebView/') >= 0); + +export function hasClipboardSupport() { + if (isIE) { + return false; + } + + if (isEdge) { + let index = userAgent.indexOf('Edge/'); + let version = parseInt(userAgent.substring(index + 5, userAgent.indexOf('.', index)), 10); + + if (!version || (version >= 12 && version <= 16)) { + return false; + } + } + + return true; +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/browser/event.ts b/src/main/custom-electron-titlebar/browser/event.ts new file mode 100644 index 0000000..ac5b0bd --- /dev/null +++ b/src/main/custom-electron-titlebar/browser/event.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event, Emitter } from '../common/event'; + +export type EventHandler = HTMLElement | HTMLDocument | Window; + +export interface IDomEvent { + (element: EventHandler, type: K, useCapture?: boolean): Event; + (element: EventHandler, type: string, useCapture?: boolean): Event; +} + +export const domEvent: IDomEvent = (element: EventHandler, type: string, useCapture?: boolean) => { + const fn = e => emitter.fire(e); + const emitter = new Emitter({ + onFirstListenerAdd: () => { + element.addEventListener(type, fn, useCapture); + }, + onLastListenerRemove: () => { + element.removeEventListener(type, fn, useCapture); + } + }); + + return emitter.event; +}; + +export interface CancellableEvent { + preventDefault(); + stopPropagation(); +} + +export function stop(event: Event): Event { + return Event.map(event, e => { + e.preventDefault(); + e.stopPropagation(); + return e; + }); +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/browser/iframe.ts b/src/main/custom-electron-titlebar/browser/iframe.ts new file mode 100644 index 0000000..8605343 --- /dev/null +++ b/src/main/custom-electron-titlebar/browser/iframe.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Represents a window in a possible chain of iframes + */ +export interface IWindowChainElement { + /** + * The window object for it + */ + window: Window; + /** + * The iframe element inside the window.parent corresponding to window + */ + iframeElement: HTMLIFrameElement | null; +} + +let hasDifferentOriginAncestorFlag: boolean = false; +let sameOriginWindowChainCache: IWindowChainElement[] | null = null; + +function getParentWindowIfSameOrigin(w: Window): Window | null { + if (!w.parent || w.parent === w) { + return null; + } + + // Cannot really tell if we have access to the parent window unless we try to access something in it + try { + let location = w.location; + let parentLocation = w.parent.location; + if (location.protocol !== parentLocation.protocol || location.hostname !== parentLocation.hostname || location.port !== parentLocation.port) { + hasDifferentOriginAncestorFlag = true; + return null; + } + } catch (e) { + hasDifferentOriginAncestorFlag = true; + return null; + } + + return w.parent; +} + +function findIframeElementInParentWindow(parentWindow: Window, childWindow: Window): HTMLIFrameElement | null { + let parentWindowIframes = parentWindow.document.getElementsByTagName('iframe'); + let iframe: HTMLIFrameElement; + for (let i = 0, len = parentWindowIframes.length; i < len; i++) { + iframe = parentWindowIframes[i]; + if (iframe.contentWindow === childWindow) { + return iframe; + } + } + return null; +} + +export class IframeUtils { + + /** + * Returns a chain of embedded windows with the same origin (which can be accessed programmatically). + * Having a chain of length 1 might mean that the current execution environment is running outside of an iframe or inside an iframe embedded in a window with a different origin. + * To distinguish if at one point the current execution environment is running inside a window with a different origin, see hasDifferentOriginAncestor() + */ + public static getSameOriginWindowChain(): IWindowChainElement[] { + if (!sameOriginWindowChainCache) { + sameOriginWindowChainCache = []; + let w: Window | null = window; + let parent: Window | null; + do { + parent = getParentWindowIfSameOrigin(w); + if (parent) { + sameOriginWindowChainCache.push({ + window: w, + iframeElement: findIframeElementInParentWindow(parent, w) + }); + } else { + sameOriginWindowChainCache.push({ + window: w, + iframeElement: null + }); + } + w = parent; + } while (w); + } + return sameOriginWindowChainCache.slice(0); + } + + /** + * Returns true if the current execution environment is chained in a list of iframes which at one point ends in a window with a different origin. + * Returns false if the current execution environment is not running inside an iframe or if the entire chain of iframes have the same origin. + */ + public static hasDifferentOriginAncestor(): boolean { + if (!sameOriginWindowChainCache) { + this.getSameOriginWindowChain(); + } + return hasDifferentOriginAncestorFlag; + } + + /** + * Returns the position of `childWindow` relative to `ancestorWindow` + */ + public static getPositionOfChildWindowRelativeToAncestorWindow(childWindow: Window, ancestorWindow: any) { + + if (!ancestorWindow || childWindow === ancestorWindow) { + return { + top: 0, + left: 0 + }; + } + + let top = 0, left = 0; + + let windowChain = this.getSameOriginWindowChain(); + + for (const windowChainEl of windowChain) { + + if (windowChainEl.window === ancestorWindow) { + break; + } + + if (!windowChainEl.iframeElement) { + break; + } + + let boundingRect = windowChainEl.iframeElement.getBoundingClientRect(); + top += boundingRect.top; + left += boundingRect.left; + } + + return { + top: top, + left: left + }; + } +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/browser/keyboardEvent.ts b/src/main/custom-electron-titlebar/browser/keyboardEvent.ts new file mode 100644 index 0000000..5a968df --- /dev/null +++ b/src/main/custom-electron-titlebar/browser/keyboardEvent.ts @@ -0,0 +1,287 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeyCode, KeyCodeUtils, KeyMod, SimpleKeybinding } from '../common/keyCodes'; +import * as platform from '../common/platform'; + +let KEY_CODE_MAP: { [keyCode: number]: KeyCode } = new Array(230); +let INVERSE_KEY_CODE_MAP: KeyCode[] = new Array(KeyCode.MAX_VALUE); + +(function () { + for (let i = 0; i < INVERSE_KEY_CODE_MAP.length; i++) { + INVERSE_KEY_CODE_MAP[i] = -1; + } + + function define(code: number, keyCode: KeyCode): void { + KEY_CODE_MAP[code] = keyCode; + INVERSE_KEY_CODE_MAP[keyCode] = code; + } + + define(3, KeyCode.PauseBreak); // VK_CANCEL 0x03 Control-break processing + define(8, KeyCode.Backspace); + define(9, KeyCode.Tab); + define(13, KeyCode.Enter); + define(16, KeyCode.Shift); + define(17, KeyCode.Ctrl); + define(18, KeyCode.Alt); + define(19, KeyCode.PauseBreak); + define(20, KeyCode.CapsLock); + define(27, KeyCode.Escape); + define(32, KeyCode.Space); + define(33, KeyCode.PageUp); + define(34, KeyCode.PageDown); + define(35, KeyCode.End); + define(36, KeyCode.Home); + define(37, KeyCode.LeftArrow); + define(38, KeyCode.UpArrow); + define(39, KeyCode.RightArrow); + define(40, KeyCode.DownArrow); + define(45, KeyCode.Insert); + define(46, KeyCode.Delete); + + define(48, KeyCode.KEY_0); + define(49, KeyCode.KEY_1); + define(50, KeyCode.KEY_2); + define(51, KeyCode.KEY_3); + define(52, KeyCode.KEY_4); + define(53, KeyCode.KEY_5); + define(54, KeyCode.KEY_6); + define(55, KeyCode.KEY_7); + define(56, KeyCode.KEY_8); + define(57, KeyCode.KEY_9); + + define(65, KeyCode.KEY_A); + define(66, KeyCode.KEY_B); + define(67, KeyCode.KEY_C); + define(68, KeyCode.KEY_D); + define(69, KeyCode.KEY_E); + define(70, KeyCode.KEY_F); + define(71, KeyCode.KEY_G); + define(72, KeyCode.KEY_H); + define(73, KeyCode.KEY_I); + define(74, KeyCode.KEY_J); + define(75, KeyCode.KEY_K); + define(76, KeyCode.KEY_L); + define(77, KeyCode.KEY_M); + define(78, KeyCode.KEY_N); + define(79, KeyCode.KEY_O); + define(80, KeyCode.KEY_P); + define(81, KeyCode.KEY_Q); + define(82, KeyCode.KEY_R); + define(83, KeyCode.KEY_S); + define(84, KeyCode.KEY_T); + define(85, KeyCode.KEY_U); + define(86, KeyCode.KEY_V); + define(87, KeyCode.KEY_W); + define(88, KeyCode.KEY_X); + define(89, KeyCode.KEY_Y); + define(90, KeyCode.KEY_Z); + + define(93, KeyCode.ContextMenu); + + define(96, KeyCode.NUMPAD_0); + define(97, KeyCode.NUMPAD_1); + define(98, KeyCode.NUMPAD_2); + define(99, KeyCode.NUMPAD_3); + define(100, KeyCode.NUMPAD_4); + define(101, KeyCode.NUMPAD_5); + define(102, KeyCode.NUMPAD_6); + define(103, KeyCode.NUMPAD_7); + define(104, KeyCode.NUMPAD_8); + define(105, KeyCode.NUMPAD_9); + define(106, KeyCode.NUMPAD_MULTIPLY); + define(107, KeyCode.NUMPAD_ADD); + define(108, KeyCode.NUMPAD_SEPARATOR); + define(109, KeyCode.NUMPAD_SUBTRACT); + define(110, KeyCode.NUMPAD_DECIMAL); + define(111, KeyCode.NUMPAD_DIVIDE); + + define(112, KeyCode.F1); + define(113, KeyCode.F2); + define(114, KeyCode.F3); + define(115, KeyCode.F4); + define(116, KeyCode.F5); + define(117, KeyCode.F6); + define(118, KeyCode.F7); + define(119, KeyCode.F8); + define(120, KeyCode.F9); + define(121, KeyCode.F10); + define(122, KeyCode.F11); + define(123, KeyCode.F12); + define(124, KeyCode.F13); + define(125, KeyCode.F14); + define(126, KeyCode.F15); + define(127, KeyCode.F16); + define(128, KeyCode.F17); + define(129, KeyCode.F18); + define(130, KeyCode.F19); + + define(144, KeyCode.NumLock); + define(145, KeyCode.ScrollLock); + + define(186, KeyCode.US_SEMICOLON); + define(187, KeyCode.US_EQUAL); + define(188, KeyCode.US_COMMA); + define(189, KeyCode.US_MINUS); + define(190, KeyCode.US_DOT); + define(191, KeyCode.US_SLASH); + define(192, KeyCode.US_BACKTICK); + define(193, KeyCode.ABNT_C1); + define(194, KeyCode.ABNT_C2); + define(219, KeyCode.US_OPEN_SQUARE_BRACKET); + define(220, KeyCode.US_BACKSLASH); + define(221, KeyCode.US_CLOSE_SQUARE_BRACKET); + define(222, KeyCode.US_QUOTE); + define(223, KeyCode.OEM_8); + + define(226, KeyCode.OEM_102); + + /** + * https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html + * If an Input Method Editor is processing key input and the event is keydown, return 229. + */ + define(229, KeyCode.KEY_IN_COMPOSITION); + + define(91, KeyCode.Meta); + if (platform.isMacintosh) { + // the two meta keys in the Mac have different key codes (91 and 93) + define(93, KeyCode.Meta); + } else { + define(92, KeyCode.Meta); + } +})(); + +function extractKeyCode(e: KeyboardEvent): KeyCode { + if (e.charCode) { + // "keypress" events mostly + let char = String.fromCharCode(e.charCode).toUpperCase(); + return KeyCodeUtils.fromString(char); + } + return KEY_CODE_MAP[e.keyCode] || KeyCode.Unknown; +} + +export function getCodeForKeyCode(keyCode: KeyCode): number { + return INVERSE_KEY_CODE_MAP[keyCode]; +} + +export interface IKeyboardEvent { + readonly browserEvent: KeyboardEvent; + readonly target: HTMLElement; + + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + readonly keyCode: KeyCode; + readonly code: string; + + /** + * @internal + */ + toKeybinding(): SimpleKeybinding; + equals(keybinding: number): boolean; + + preventDefault(): void; + stopPropagation(): void; +} + +const ctrlKeyMod = (platform.isMacintosh ? KeyMod.WinCtrl : KeyMod.CtrlCmd); +const altKeyMod = KeyMod.Alt; +const shiftKeyMod = KeyMod.Shift; +const metaKeyMod = (platform.isMacintosh ? KeyMod.CtrlCmd : KeyMod.WinCtrl); + +export class StandardKeyboardEvent implements IKeyboardEvent { + + public readonly browserEvent: KeyboardEvent; + public readonly target: HTMLElement; + + public readonly ctrlKey: boolean; + public readonly shiftKey: boolean; + public readonly altKey: boolean; + public readonly metaKey: boolean; + public readonly keyCode: KeyCode; + public readonly code: string; + + private _asKeybinding: number; + private _asRuntimeKeybinding: SimpleKeybinding; + + constructor(source: KeyboardEvent) { + let e = source; + + this.browserEvent = e; + this.target = e.target; + + this.ctrlKey = e.ctrlKey; + this.shiftKey = e.shiftKey; + this.altKey = e.altKey; + this.metaKey = e.metaKey; + this.keyCode = extractKeyCode(e); + this.code = e.code; + + // console.info(e.type + ": keyCode: " + e.keyCode + ", which: " + e.which + ", charCode: " + e.charCode + ", detail: " + e.detail + " ====> " + this.keyCode + ' -- ' + KeyCode[this.keyCode]); + + this.ctrlKey = this.ctrlKey || this.keyCode === KeyCode.Ctrl; + this.altKey = this.altKey || this.keyCode === KeyCode.Alt; + this.shiftKey = this.shiftKey || this.keyCode === KeyCode.Shift; + this.metaKey = this.metaKey || this.keyCode === KeyCode.Meta; + + this._asKeybinding = this._computeKeybinding(); + this._asRuntimeKeybinding = this._computeRuntimeKeybinding(); + + // console.log(`code: ${e.code}, keyCode: ${e.keyCode}, key: ${e.key}`); + } + + public preventDefault(): void { + if (this.browserEvent && this.browserEvent.preventDefault) { + this.browserEvent.preventDefault(); + } + } + + public stopPropagation(): void { + if (this.browserEvent && this.browserEvent.stopPropagation) { + this.browserEvent.stopPropagation(); + } + } + + public toKeybinding(): SimpleKeybinding { + return this._asRuntimeKeybinding; + } + + public equals(other: number): boolean { + return this._asKeybinding === other; + } + + private _computeKeybinding(): number { + let key = KeyCode.Unknown; + if (this.keyCode !== KeyCode.Ctrl && this.keyCode !== KeyCode.Shift && this.keyCode !== KeyCode.Alt && this.keyCode !== KeyCode.Meta) { + key = this.keyCode; + } + + let result = 0; + if (this.ctrlKey) { + result |= ctrlKeyMod; + } + if (this.altKey) { + result |= altKeyMod; + } + if (this.shiftKey) { + result |= shiftKeyMod; + } + if (this.metaKey) { + result |= metaKeyMod; + } + result |= key; + + return result; + } + + private _computeRuntimeKeybinding(): SimpleKeybinding { + let key = KeyCode.Unknown; + if (this.keyCode !== KeyCode.Ctrl && this.keyCode !== KeyCode.Shift && this.keyCode !== KeyCode.Alt && this.keyCode !== KeyCode.Meta) { + key = this.keyCode; + } + return new SimpleKeybinding(this.ctrlKey, this.shiftKey, this.altKey, this.metaKey, key); + } +} diff --git a/src/main/custom-electron-titlebar/browser/mouseEvent.ts b/src/main/custom-electron-titlebar/browser/mouseEvent.ts new file mode 100644 index 0000000..d89e5f2 --- /dev/null +++ b/src/main/custom-electron-titlebar/browser/mouseEvent.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * 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 './browser'; +import { IframeUtils } from './iframe'; +import * as platform from '../common/platform'; + +export interface IMouseEvent { + readonly browserEvent: MouseEvent; + readonly leftButton: boolean; + readonly middleButton: boolean; + readonly rightButton: boolean; + readonly target: HTMLElement; + readonly detail: number; + readonly posx: number; + readonly posy: number; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + readonly timestamp: number; + + preventDefault(): void; + stopPropagation(): void; +} + +export class StandardMouseEvent implements IMouseEvent { + + public readonly browserEvent: MouseEvent; + + public readonly leftButton: boolean; + public readonly middleButton: boolean; + public readonly rightButton: boolean; + public readonly target: HTMLElement; + public detail: number; + public readonly posx: number; + public readonly posy: number; + public readonly ctrlKey: boolean; + public readonly shiftKey: boolean; + public readonly altKey: boolean; + public readonly metaKey: boolean; + public readonly timestamp: number; + + constructor(e: MouseEvent) { + this.timestamp = Date.now(); + this.browserEvent = e; + this.leftButton = e.button === 0; + this.middleButton = e.button === 1; + this.rightButton = e.button === 2; + + this.target = e.target; + + this.detail = e.detail || 1; + if (e.type === 'dblclick') { + this.detail = 2; + } + this.ctrlKey = e.ctrlKey; + this.shiftKey = e.shiftKey; + this.altKey = e.altKey; + this.metaKey = e.metaKey; + + if (typeof e.pageX === 'number') { + this.posx = e.pageX; + this.posy = e.pageY; + } else { + // Probably hit by MSGestureEvent + this.posx = e.clientX + document.body.scrollLeft + document.documentElement!.scrollLeft; + this.posy = e.clientY + document.body.scrollTop + document.documentElement!.scrollTop; + } + + // Find the position of the iframe this code is executing in relative to the iframe where the event was captured. + let iframeOffsets = IframeUtils.getPositionOfChildWindowRelativeToAncestorWindow(self, e.view); + this.posx -= iframeOffsets.left; + this.posy -= iframeOffsets.top; + } + + public preventDefault(): void { + if (this.browserEvent.preventDefault) { + this.browserEvent.preventDefault(); + } + } + + public stopPropagation(): void { + if (this.browserEvent.stopPropagation) { + this.browserEvent.stopPropagation(); + } + } +} + +export interface IDataTransfer { + dropEffect: string; + effectAllowed: string; + types: any[]; + files: any[]; + + setData(type: string, data: string): void; + setDragImage(image: any, x: number, y: number): void; + + getData(type: string): string; + clearData(types?: string[]): void; +} + +export class DragMouseEvent extends StandardMouseEvent { + + public readonly dataTransfer: IDataTransfer; + + constructor(e: MouseEvent) { + super(e); + this.dataTransfer = (e).dataTransfer; + } + +} + +export interface IMouseWheelEvent extends MouseEvent { + readonly wheelDelta: number; +} + +interface IWebKitMouseWheelEvent { + wheelDeltaY: number; + wheelDeltaX: number; +} + +interface IGeckoMouseWheelEvent { + HORIZONTAL_AXIS: number; + VERTICAL_AXIS: number; + axis: number; + detail: number; +} + +export class StandardWheelEvent { + + public readonly browserEvent: IMouseWheelEvent | null; + public readonly deltaY: number; + public readonly deltaX: number; + public readonly target: Node; + + constructor(e: IMouseWheelEvent | null, deltaX: number = 0, deltaY: number = 0) { + + this.browserEvent = e || null; + this.target = e ? (e.target || (e).targetNode || e.srcElement) : null; + + this.deltaY = deltaY; + this.deltaX = deltaX; + + if (e) { + let e1 = e; + let e2 = e; + + // vertical delta scroll + if (typeof e1.wheelDeltaY !== 'undefined') { + this.deltaY = e1.wheelDeltaY / 120; + } else if (typeof e2.VERTICAL_AXIS !== 'undefined' && e2.axis === e2.VERTICAL_AXIS) { + this.deltaY = -e2.detail / 3; + } + + // horizontal delta scroll + if (typeof e1.wheelDeltaX !== 'undefined') { + if (browser.isSafari && platform.isWindows) { + this.deltaX = - (e1.wheelDeltaX / 120); + } else { + this.deltaX = e1.wheelDeltaX / 120; + } + } else if (typeof e2.HORIZONTAL_AXIS !== 'undefined' && e2.axis === e2.HORIZONTAL_AXIS) { + this.deltaX = -e.detail / 3; + } + + // Assume a vertical scroll if nothing else worked + if (this.deltaY === 0 && this.deltaX === 0 && e.wheelDelta) { + this.deltaY = e.wheelDelta / 120; + } + } + } + + public preventDefault(): void { + if (this.browserEvent) { + if (this.browserEvent.preventDefault) { + this.browserEvent.preventDefault(); + } + } + } + + public stopPropagation(): void { + if (this.browserEvent) { + if (this.browserEvent.stopPropagation) { + this.browserEvent.stopPropagation(); + } + } + } +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/common/arrays.ts b/src/main/custom-electron-titlebar/common/arrays.ts new file mode 100644 index 0000000..6d0e6e9 --- /dev/null +++ b/src/main/custom-electron-titlebar/common/arrays.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * @returns a new array with all falsy values removed. The original array IS NOT modified. + */ +export function coalesce(array: Array): T[] { + if (!array) { + return array; + } + return array.filter(e => !!e); +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/common/async.ts b/src/main/custom-electron-titlebar/common/async.ts new file mode 100644 index 0000000..9b95da7 --- /dev/null +++ b/src/main/custom-electron-titlebar/common/async.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from './lifecycle'; + +export class TimeoutTimer extends Disposable { + private _token: any; + + constructor(); + constructor(runner: () => void, timeout: number); + constructor(runner?: () => void, timeout?: number) { + super(); + this._token = -1; + + if (typeof runner === 'function' && typeof timeout === 'number') { + this.setIfNotSet(runner, timeout); + } + } + + dispose(): void { + this.cancel(); + super.dispose(); + } + + cancel(): void { + if (this._token !== -1) { + clearTimeout(this._token); + this._token = -1; + } + } + + cancelAndSet(runner: () => void, timeout: number): void { + this.cancel(); + this._token = setTimeout(() => { + this._token = -1; + runner(); + }, timeout); + } + + setIfNotSet(runner: () => void, timeout: number): void { + if (this._token !== -1) { + // timer is already set + return; + } + this._token = setTimeout(() => { + this._token = -1; + runner(); + }, timeout); + } +} + +export class RunOnceScheduler { + + protected runner: ((...args: any[]) => void) | null; + + private timeoutToken: any; + private timeout: number; + private timeoutHandler: () => void; + + constructor(runner: (...args: any[]) => void, timeout: number) { + this.timeoutToken = -1; + this.runner = runner; + this.timeout = timeout; + this.timeoutHandler = this.onTimeout.bind(this); + } + + /** + * Dispose RunOnceScheduler + */ + dispose(): void { + this.cancel(); + this.runner = null; + } + + /** + * Cancel current scheduled runner (if any). + */ + cancel(): void { + if (this.isScheduled()) { + clearTimeout(this.timeoutToken); + this.timeoutToken = -1; + } + } + + /** + * Cancel previous runner (if any) & schedule a new runner. + */ + schedule(delay = this.timeout): void { + this.cancel(); + this.timeoutToken = setTimeout(this.timeoutHandler, delay); + } + + /** + * Returns true if scheduled. + */ + isScheduled(): boolean { + return this.timeoutToken !== -1; + } + + private onTimeout() { + this.timeoutToken = -1; + if (this.runner) { + this.doRun(); + } + } + + protected doRun(): void { + if (this.runner) { + this.runner(); + } + } +} diff --git a/src/main/custom-electron-titlebar/common/charCode.ts b/src/main/custom-electron-titlebar/common/charCode.ts new file mode 100644 index 0000000..3b8c642 --- /dev/null +++ b/src/main/custom-electron-titlebar/common/charCode.ts @@ -0,0 +1,425 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/ + +/** + * An inlined enum containing useful character codes (to be used with String.charCodeAt). + * Please leave the const keyword such that it gets inlined when compiled to JavaScript! + */ +export const enum CharCode { + Null = 0, + /** + * The `\b` character. + */ + Backspace = 8, + /** + * The `\t` character. + */ + Tab = 9, + /** + * The `\n` character. + */ + LineFeed = 10, + /** + * The `\r` character. + */ + CarriageReturn = 13, + Space = 32, + /** + * The `!` character. + */ + ExclamationMark = 33, + /** + * The `"` character. + */ + DoubleQuote = 34, + /** + * The `#` character. + */ + Hash = 35, + /** + * The `$` character. + */ + DollarSign = 36, + /** + * The `%` character. + */ + PercentSign = 37, + /** + * The `&` character. + */ + Ampersand = 38, + /** + * The `'` character. + */ + SingleQuote = 39, + /** + * The `(` character. + */ + OpenParen = 40, + /** + * The `)` character. + */ + CloseParen = 41, + /** + * The `*` character. + */ + Asterisk = 42, + /** + * The `+` character. + */ + Plus = 43, + /** + * The `,` character. + */ + Comma = 44, + /** + * The `-` character. + */ + Dash = 45, + /** + * The `.` character. + */ + Period = 46, + /** + * The `/` character. + */ + Slash = 47, + + Digit0 = 48, + Digit1 = 49, + Digit2 = 50, + Digit3 = 51, + Digit4 = 52, + Digit5 = 53, + Digit6 = 54, + Digit7 = 55, + Digit8 = 56, + Digit9 = 57, + + /** + * The `:` character. + */ + Colon = 58, + /** + * The `;` character. + */ + Semicolon = 59, + /** + * The `<` character. + */ + LessThan = 60, + /** + * The `=` character. + */ + Equals = 61, + /** + * The `>` character. + */ + GreaterThan = 62, + /** + * The `?` character. + */ + QuestionMark = 63, + /** + * The `@` character. + */ + AtSign = 64, + + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + + /** + * The `[` character. + */ + OpenSquareBracket = 91, + /** + * The `\` character. + */ + Backslash = 92, + /** + * The `]` character. + */ + CloseSquareBracket = 93, + /** + * The `^` character. + */ + Caret = 94, + /** + * The `_` character. + */ + Underline = 95, + /** + * The ``(`)`` character. + */ + BackTick = 96, + + a = 97, + b = 98, + c = 99, + d = 100, + e = 101, + f = 102, + g = 103, + h = 104, + i = 105, + j = 106, + k = 107, + l = 108, + m = 109, + n = 110, + o = 111, + p = 112, + q = 113, + r = 114, + s = 115, + t = 116, + u = 117, + v = 118, + w = 119, + x = 120, + y = 121, + z = 122, + + /** + * The `{` character. + */ + OpenCurlyBrace = 123, + /** + * The `|` character. + */ + Pipe = 124, + /** + * The `}` character. + */ + CloseCurlyBrace = 125, + /** + * The `~` character. + */ + Tilde = 126, + + U_Combining_Grave_Accent = 0x0300, // U+0300 Combining Grave Accent + U_Combining_Acute_Accent = 0x0301, // U+0301 Combining Acute Accent + U_Combining_Circumflex_Accent = 0x0302, // U+0302 Combining Circumflex Accent + U_Combining_Tilde = 0x0303, // U+0303 Combining Tilde + U_Combining_Macron = 0x0304, // U+0304 Combining Macron + U_Combining_Overline = 0x0305, // U+0305 Combining Overline + U_Combining_Breve = 0x0306, // U+0306 Combining Breve + U_Combining_Dot_Above = 0x0307, // U+0307 Combining Dot Above + U_Combining_Diaeresis = 0x0308, // U+0308 Combining Diaeresis + U_Combining_Hook_Above = 0x0309, // U+0309 Combining Hook Above + U_Combining_Ring_Above = 0x030A, // U+030A Combining Ring Above + U_Combining_Double_Acute_Accent = 0x030B, // U+030B Combining Double Acute Accent + U_Combining_Caron = 0x030C, // U+030C Combining Caron + U_Combining_Vertical_Line_Above = 0x030D, // U+030D Combining Vertical Line Above + U_Combining_Double_Vertical_Line_Above = 0x030E, // U+030E Combining Double Vertical Line Above + U_Combining_Double_Grave_Accent = 0x030F, // U+030F Combining Double Grave Accent + U_Combining_Candrabindu = 0x0310, // U+0310 Combining Candrabindu + U_Combining_Inverted_Breve = 0x0311, // U+0311 Combining Inverted Breve + U_Combining_Turned_Comma_Above = 0x0312, // U+0312 Combining Turned Comma Above + U_Combining_Comma_Above = 0x0313, // U+0313 Combining Comma Above + U_Combining_Reversed_Comma_Above = 0x0314, // U+0314 Combining Reversed Comma Above + U_Combining_Comma_Above_Right = 0x0315, // U+0315 Combining Comma Above Right + U_Combining_Grave_Accent_Below = 0x0316, // U+0316 Combining Grave Accent Below + U_Combining_Acute_Accent_Below = 0x0317, // U+0317 Combining Acute Accent Below + U_Combining_Left_Tack_Below = 0x0318, // U+0318 Combining Left Tack Below + U_Combining_Right_Tack_Below = 0x0319, // U+0319 Combining Right Tack Below + U_Combining_Left_Angle_Above = 0x031A, // U+031A Combining Left Angle Above + U_Combining_Horn = 0x031B, // U+031B Combining Horn + U_Combining_Left_Half_Ring_Below = 0x031C, // U+031C Combining Left Half Ring Below + U_Combining_Up_Tack_Below = 0x031D, // U+031D Combining Up Tack Below + U_Combining_Down_Tack_Below = 0x031E, // U+031E Combining Down Tack Below + U_Combining_Plus_Sign_Below = 0x031F, // U+031F Combining Plus Sign Below + U_Combining_Minus_Sign_Below = 0x0320, // U+0320 Combining Minus Sign Below + U_Combining_Palatalized_Hook_Below = 0x0321, // U+0321 Combining Palatalized Hook Below + U_Combining_Retroflex_Hook_Below = 0x0322, // U+0322 Combining Retroflex Hook Below + U_Combining_Dot_Below = 0x0323, // U+0323 Combining Dot Below + U_Combining_Diaeresis_Below = 0x0324, // U+0324 Combining Diaeresis Below + U_Combining_Ring_Below = 0x0325, // U+0325 Combining Ring Below + U_Combining_Comma_Below = 0x0326, // U+0326 Combining Comma Below + U_Combining_Cedilla = 0x0327, // U+0327 Combining Cedilla + U_Combining_Ogonek = 0x0328, // U+0328 Combining Ogonek + U_Combining_Vertical_Line_Below = 0x0329, // U+0329 Combining Vertical Line Below + U_Combining_Bridge_Below = 0x032A, // U+032A Combining Bridge Below + U_Combining_Inverted_Double_Arch_Below = 0x032B, // U+032B Combining Inverted Double Arch Below + U_Combining_Caron_Below = 0x032C, // U+032C Combining Caron Below + U_Combining_Circumflex_Accent_Below = 0x032D, // U+032D Combining Circumflex Accent Below + U_Combining_Breve_Below = 0x032E, // U+032E Combining Breve Below + U_Combining_Inverted_Breve_Below = 0x032F, // U+032F Combining Inverted Breve Below + U_Combining_Tilde_Below = 0x0330, // U+0330 Combining Tilde Below + U_Combining_Macron_Below = 0x0331, // U+0331 Combining Macron Below + U_Combining_Low_Line = 0x0332, // U+0332 Combining Low Line + U_Combining_Double_Low_Line = 0x0333, // U+0333 Combining Double Low Line + U_Combining_Tilde_Overlay = 0x0334, // U+0334 Combining Tilde Overlay + U_Combining_Short_Stroke_Overlay = 0x0335, // U+0335 Combining Short Stroke Overlay + U_Combining_Long_Stroke_Overlay = 0x0336, // U+0336 Combining Long Stroke Overlay + U_Combining_Short_Solidus_Overlay = 0x0337, // U+0337 Combining Short Solidus Overlay + U_Combining_Long_Solidus_Overlay = 0x0338, // U+0338 Combining Long Solidus Overlay + U_Combining_Right_Half_Ring_Below = 0x0339, // U+0339 Combining Right Half Ring Below + U_Combining_Inverted_Bridge_Below = 0x033A, // U+033A Combining Inverted Bridge Below + U_Combining_Square_Below = 0x033B, // U+033B Combining Square Below + U_Combining_Seagull_Below = 0x033C, // U+033C Combining Seagull Below + U_Combining_X_Above = 0x033D, // U+033D Combining X Above + U_Combining_Vertical_Tilde = 0x033E, // U+033E Combining Vertical Tilde + U_Combining_Double_Overline = 0x033F, // U+033F Combining Double Overline + U_Combining_Grave_Tone_Mark = 0x0340, // U+0340 Combining Grave Tone Mark + U_Combining_Acute_Tone_Mark = 0x0341, // U+0341 Combining Acute Tone Mark + U_Combining_Greek_Perispomeni = 0x0342, // U+0342 Combining Greek Perispomeni + U_Combining_Greek_Koronis = 0x0343, // U+0343 Combining Greek Koronis + U_Combining_Greek_Dialytika_Tonos = 0x0344, // U+0344 Combining Greek Dialytika Tonos + U_Combining_Greek_Ypogegrammeni = 0x0345, // U+0345 Combining Greek Ypogegrammeni + U_Combining_Bridge_Above = 0x0346, // U+0346 Combining Bridge Above + U_Combining_Equals_Sign_Below = 0x0347, // U+0347 Combining Equals Sign Below + U_Combining_Double_Vertical_Line_Below = 0x0348, // U+0348 Combining Double Vertical Line Below + U_Combining_Left_Angle_Below = 0x0349, // U+0349 Combining Left Angle Below + U_Combining_Not_Tilde_Above = 0x034A, // U+034A Combining Not Tilde Above + U_Combining_Homothetic_Above = 0x034B, // U+034B Combining Homothetic Above + U_Combining_Almost_Equal_To_Above = 0x034C, // U+034C Combining Almost Equal To Above + U_Combining_Left_Right_Arrow_Below = 0x034D, // U+034D Combining Left Right Arrow Below + U_Combining_Upwards_Arrow_Below = 0x034E, // U+034E Combining Upwards Arrow Below + U_Combining_Grapheme_Joiner = 0x034F, // U+034F Combining Grapheme Joiner + U_Combining_Right_Arrowhead_Above = 0x0350, // U+0350 Combining Right Arrowhead Above + U_Combining_Left_Half_Ring_Above = 0x0351, // U+0351 Combining Left Half Ring Above + U_Combining_Fermata = 0x0352, // U+0352 Combining Fermata + U_Combining_X_Below = 0x0353, // U+0353 Combining X Below + U_Combining_Left_Arrowhead_Below = 0x0354, // U+0354 Combining Left Arrowhead Below + U_Combining_Right_Arrowhead_Below = 0x0355, // U+0355 Combining Right Arrowhead Below + U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below = 0x0356, // U+0356 Combining Right Arrowhead And Up Arrowhead Below + U_Combining_Right_Half_Ring_Above = 0x0357, // U+0357 Combining Right Half Ring Above + U_Combining_Dot_Above_Right = 0x0358, // U+0358 Combining Dot Above Right + U_Combining_Asterisk_Below = 0x0359, // U+0359 Combining Asterisk Below + U_Combining_Double_Ring_Below = 0x035A, // U+035A Combining Double Ring Below + U_Combining_Zigzag_Above = 0x035B, // U+035B Combining Zigzag Above + U_Combining_Double_Breve_Below = 0x035C, // U+035C Combining Double Breve Below + U_Combining_Double_Breve = 0x035D, // U+035D Combining Double Breve + U_Combining_Double_Macron = 0x035E, // U+035E Combining Double Macron + U_Combining_Double_Macron_Below = 0x035F, // U+035F Combining Double Macron Below + U_Combining_Double_Tilde = 0x0360, // U+0360 Combining Double Tilde + U_Combining_Double_Inverted_Breve = 0x0361, // U+0361 Combining Double Inverted Breve + U_Combining_Double_Rightwards_Arrow_Below = 0x0362, // U+0362 Combining Double Rightwards Arrow Below + U_Combining_Latin_Small_Letter_A = 0x0363, // U+0363 Combining Latin Small Letter A + U_Combining_Latin_Small_Letter_E = 0x0364, // U+0364 Combining Latin Small Letter E + U_Combining_Latin_Small_Letter_I = 0x0365, // U+0365 Combining Latin Small Letter I + U_Combining_Latin_Small_Letter_O = 0x0366, // U+0366 Combining Latin Small Letter O + U_Combining_Latin_Small_Letter_U = 0x0367, // U+0367 Combining Latin Small Letter U + U_Combining_Latin_Small_Letter_C = 0x0368, // U+0368 Combining Latin Small Letter C + U_Combining_Latin_Small_Letter_D = 0x0369, // U+0369 Combining Latin Small Letter D + U_Combining_Latin_Small_Letter_H = 0x036A, // U+036A Combining Latin Small Letter H + U_Combining_Latin_Small_Letter_M = 0x036B, // U+036B Combining Latin Small Letter M + U_Combining_Latin_Small_Letter_R = 0x036C, // U+036C Combining Latin Small Letter R + U_Combining_Latin_Small_Letter_T = 0x036D, // U+036D Combining Latin Small Letter T + U_Combining_Latin_Small_Letter_V = 0x036E, // U+036E Combining Latin Small Letter V + U_Combining_Latin_Small_Letter_X = 0x036F, // U+036F Combining Latin Small Letter X + + /** + * Unicode Character 'LINE SEPARATOR' (U+2028) + * http://www.fileformat.info/info/unicode/char/2028/index.htm + */ + LINE_SEPARATOR_2028 = 8232, + + // http://www.fileformat.info/info/unicode/category/Sk/list.htm + U_CIRCUMFLEX = 0x005E, // U+005E CIRCUMFLEX + U_GRAVE_ACCENT = 0x0060, // U+0060 GRAVE ACCENT + U_DIAERESIS = 0x00A8, // U+00A8 DIAERESIS + U_MACRON = 0x00AF, // U+00AF MACRON + U_ACUTE_ACCENT = 0x00B4, // U+00B4 ACUTE ACCENT + U_CEDILLA = 0x00B8, // U+00B8 CEDILLA + U_MODIFIER_LETTER_LEFT_ARROWHEAD = 0x02C2, // U+02C2 MODIFIER LETTER LEFT ARROWHEAD + U_MODIFIER_LETTER_RIGHT_ARROWHEAD = 0x02C3, // U+02C3 MODIFIER LETTER RIGHT ARROWHEAD + U_MODIFIER_LETTER_UP_ARROWHEAD = 0x02C4, // U+02C4 MODIFIER LETTER UP ARROWHEAD + U_MODIFIER_LETTER_DOWN_ARROWHEAD = 0x02C5, // U+02C5 MODIFIER LETTER DOWN ARROWHEAD + U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING = 0x02D2, // U+02D2 MODIFIER LETTER CENTRED RIGHT HALF RING + U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING = 0x02D3, // U+02D3 MODIFIER LETTER CENTRED LEFT HALF RING + U_MODIFIER_LETTER_UP_TACK = 0x02D4, // U+02D4 MODIFIER LETTER UP TACK + U_MODIFIER_LETTER_DOWN_TACK = 0x02D5, // U+02D5 MODIFIER LETTER DOWN TACK + U_MODIFIER_LETTER_PLUS_SIGN = 0x02D6, // U+02D6 MODIFIER LETTER PLUS SIGN + U_MODIFIER_LETTER_MINUS_SIGN = 0x02D7, // U+02D7 MODIFIER LETTER MINUS SIGN + U_BREVE = 0x02D8, // U+02D8 BREVE + U_DOT_ABOVE = 0x02D9, // U+02D9 DOT ABOVE + U_RING_ABOVE = 0x02DA, // U+02DA RING ABOVE + U_OGONEK = 0x02DB, // U+02DB OGONEK + U_SMALL_TILDE = 0x02DC, // U+02DC SMALL TILDE + U_DOUBLE_ACUTE_ACCENT = 0x02DD, // U+02DD DOUBLE ACUTE ACCENT + U_MODIFIER_LETTER_RHOTIC_HOOK = 0x02DE, // U+02DE MODIFIER LETTER RHOTIC HOOK + U_MODIFIER_LETTER_CROSS_ACCENT = 0x02DF, // U+02DF MODIFIER LETTER CROSS ACCENT + U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR = 0x02E5, // U+02E5 MODIFIER LETTER EXTRA-HIGH TONE BAR + U_MODIFIER_LETTER_HIGH_TONE_BAR = 0x02E6, // U+02E6 MODIFIER LETTER HIGH TONE BAR + U_MODIFIER_LETTER_MID_TONE_BAR = 0x02E7, // U+02E7 MODIFIER LETTER MID TONE BAR + U_MODIFIER_LETTER_LOW_TONE_BAR = 0x02E8, // U+02E8 MODIFIER LETTER LOW TONE BAR + U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR = 0x02E9, // U+02E9 MODIFIER LETTER EXTRA-LOW TONE BAR + U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK = 0x02EA, // U+02EA MODIFIER LETTER YIN DEPARTING TONE MARK + U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK = 0x02EB, // U+02EB MODIFIER LETTER YANG DEPARTING TONE MARK + U_MODIFIER_LETTER_UNASPIRATED = 0x02ED, // U+02ED MODIFIER LETTER UNASPIRATED + U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD = 0x02EF, // U+02EF MODIFIER LETTER LOW DOWN ARROWHEAD + U_MODIFIER_LETTER_LOW_UP_ARROWHEAD = 0x02F0, // U+02F0 MODIFIER LETTER LOW UP ARROWHEAD + U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD = 0x02F1, // U+02F1 MODIFIER LETTER LOW LEFT ARROWHEAD + U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD = 0x02F2, // U+02F2 MODIFIER LETTER LOW RIGHT ARROWHEAD + U_MODIFIER_LETTER_LOW_RING = 0x02F3, // U+02F3 MODIFIER LETTER LOW RING + U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT = 0x02F4, // U+02F4 MODIFIER LETTER MIDDLE GRAVE ACCENT + U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT = 0x02F5, // U+02F5 MODIFIER LETTER MIDDLE DOUBLE GRAVE ACCENT + U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT = 0x02F6, // U+02F6 MODIFIER LETTER MIDDLE DOUBLE ACUTE ACCENT + U_MODIFIER_LETTER_LOW_TILDE = 0x02F7, // U+02F7 MODIFIER LETTER LOW TILDE + U_MODIFIER_LETTER_RAISED_COLON = 0x02F8, // U+02F8 MODIFIER LETTER RAISED COLON + U_MODIFIER_LETTER_BEGIN_HIGH_TONE = 0x02F9, // U+02F9 MODIFIER LETTER BEGIN HIGH TONE + U_MODIFIER_LETTER_END_HIGH_TONE = 0x02FA, // U+02FA MODIFIER LETTER END HIGH TONE + U_MODIFIER_LETTER_BEGIN_LOW_TONE = 0x02FB, // U+02FB MODIFIER LETTER BEGIN LOW TONE + U_MODIFIER_LETTER_END_LOW_TONE = 0x02FC, // U+02FC MODIFIER LETTER END LOW TONE + U_MODIFIER_LETTER_SHELF = 0x02FD, // U+02FD MODIFIER LETTER SHELF + U_MODIFIER_LETTER_OPEN_SHELF = 0x02FE, // U+02FE MODIFIER LETTER OPEN SHELF + U_MODIFIER_LETTER_LOW_LEFT_ARROW = 0x02FF, // U+02FF MODIFIER LETTER LOW LEFT ARROW + U_GREEK_LOWER_NUMERAL_SIGN = 0x0375, // U+0375 GREEK LOWER NUMERAL SIGN + U_GREEK_TONOS = 0x0384, // U+0384 GREEK TONOS + U_GREEK_DIALYTIKA_TONOS = 0x0385, // U+0385 GREEK DIALYTIKA TONOS + U_GREEK_KORONIS = 0x1FBD, // U+1FBD GREEK KORONIS + U_GREEK_PSILI = 0x1FBF, // U+1FBF GREEK PSILI + U_GREEK_PERISPOMENI = 0x1FC0, // U+1FC0 GREEK PERISPOMENI + U_GREEK_DIALYTIKA_AND_PERISPOMENI = 0x1FC1, // U+1FC1 GREEK DIALYTIKA AND PERISPOMENI + U_GREEK_PSILI_AND_VARIA = 0x1FCD, // U+1FCD GREEK PSILI AND VARIA + U_GREEK_PSILI_AND_OXIA = 0x1FCE, // U+1FCE GREEK PSILI AND OXIA + U_GREEK_PSILI_AND_PERISPOMENI = 0x1FCF, // U+1FCF GREEK PSILI AND PERISPOMENI + U_GREEK_DASIA_AND_VARIA = 0x1FDD, // U+1FDD GREEK DASIA AND VARIA + U_GREEK_DASIA_AND_OXIA = 0x1FDE, // U+1FDE GREEK DASIA AND OXIA + U_GREEK_DASIA_AND_PERISPOMENI = 0x1FDF, // U+1FDF GREEK DASIA AND PERISPOMENI + U_GREEK_DIALYTIKA_AND_VARIA = 0x1FED, // U+1FED GREEK DIALYTIKA AND VARIA + U_GREEK_DIALYTIKA_AND_OXIA = 0x1FEE, // U+1FEE GREEK DIALYTIKA AND OXIA + U_GREEK_VARIA = 0x1FEF, // U+1FEF GREEK VARIA + U_GREEK_OXIA = 0x1FFD, // U+1FFD GREEK OXIA + U_GREEK_DASIA = 0x1FFE, // U+1FFE GREEK DASIA + + + U_OVERLINE = 0x203E, // Unicode Character 'OVERLINE' + + /** + * UTF-8 BOM + * Unicode Character 'ZERO WIDTH NO-BREAK SPACE' (U+FEFF) + * http://www.fileformat.info/info/unicode/char/feff/index.htm + */ + UTF8_BOM = 65279 +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/common/color.ts b/src/main/custom-electron-titlebar/common/color.ts new file mode 100644 index 0000000..3da141c --- /dev/null +++ b/src/main/custom-electron-titlebar/common/color.ts @@ -0,0 +1,611 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CharCode } from './charCode'; + +function roundFloat(number: number, decimalPoints: number): number { + const decimal = Math.pow(10, decimalPoints); + return Math.round(number * decimal) / decimal; +} + +export class RGBA { + _rgbaBrand: void; + + /** + * Red: integer in [0-255] + */ + readonly r: number; + + /** + * Green: integer in [0-255] + */ + readonly g: number; + + /** + * Blue: integer in [0-255] + */ + readonly b: number; + + /** + * Alpha: float in [0-1] + */ + readonly a: number; + + constructor(r: number, g: number, b: number, a: number = 1) { + this.r = Math.min(255, Math.max(0, r)) | 0; + this.g = Math.min(255, Math.max(0, g)) | 0; + this.b = Math.min(255, Math.max(0, b)) | 0; + this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); + } + + static equals(a: RGBA, b: RGBA): boolean { + return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a; + } +} + +export class HSLA { + + _hslaBrand: void; + + /** + * Hue: integer in [0, 360] + */ + readonly h: number; + + /** + * Saturation: float in [0, 1] + */ + readonly s: number; + + /** + * Luminosity: float in [0, 1] + */ + readonly l: number; + + /** + * Alpha: float in [0, 1] + */ + readonly a: number; + + constructor(h: number, s: number, l: number, a: number) { + this.h = Math.max(Math.min(360, h), 0) | 0; + this.s = roundFloat(Math.max(Math.min(1, s), 0), 3); + this.l = roundFloat(Math.max(Math.min(1, l), 0), 3); + this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); + } + + static equals(a: HSLA, b: HSLA): boolean { + return a.h === b.h && a.s === b.s && a.l === b.l && a.a === b.a; + } + + /** + * Converts an RGB color value to HSL. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes r, g, and b are contained in the set [0, 255] and + * returns h in the set [0, 360], s, and l in the set [0, 1]. + */ + static fromRGBA(rgba: RGBA): HSLA { + const r = rgba.r / 255; + const g = rgba.g / 255; + const b = rgba.b / 255; + const a = rgba.a; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (min + max) / 2; + const chroma = max - min; + + if (chroma > 0) { + s = Math.min((l <= 0.5 ? chroma / (2 * l) : chroma / (2 - (2 * l))), 1); + + switch (max) { + case r: h = (g - b) / chroma + (g < b ? 6 : 0); break; + case g: h = (b - r) / chroma + 2; break; + case b: h = (r - g) / chroma + 4; break; + } + + h *= 60; + h = Math.round(h); + } + return new HSLA(h, s, l, a); + } + + private static _hue2rgb(p: number, q: number, t: number): number { + if (t < 0) { + t += 1; + } + if (t > 1) { + t -= 1; + } + if (t < 1 / 6) { + return p + (q - p) * 6 * t; + } + if (t < 1 / 2) { + return q; + } + if (t < 2 / 3) { + return p + (q - p) * (2 / 3 - t) * 6; + } + return p; + } + + /** + * Converts an HSL color value to RGB. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes h in the set [0, 360] s, and l are contained in the set [0, 1] and + * returns r, g, and b in the set [0, 255]. + */ + static toRGBA(hsla: HSLA): RGBA { + const h = hsla.h / 360; + const { s, l, a } = hsla; + let r: number, g: number, b: number; + + if (s === 0) { + r = g = b = l; // achromatic + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = HSLA._hue2rgb(p, q, h + 1 / 3); + g = HSLA._hue2rgb(p, q, h); + b = HSLA._hue2rgb(p, q, h - 1 / 3); + } + + return new RGBA(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a); + } +} + +export class HSVA { + + _hsvaBrand: void; + + /** + * Hue: integer in [0, 360] + */ + readonly h: number; + + /** + * Saturation: float in [0, 1] + */ + readonly s: number; + + /** + * Value: float in [0, 1] + */ + readonly v: number; + + /** + * Alpha: float in [0, 1] + */ + readonly a: number; + + constructor(h: number, s: number, v: number, a: number) { + this.h = Math.max(Math.min(360, h), 0) | 0; + this.s = roundFloat(Math.max(Math.min(1, s), 0), 3); + this.v = roundFloat(Math.max(Math.min(1, v), 0), 3); + this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); + } + + static equals(a: HSVA, b: HSVA): boolean { + return a.h === b.h && a.s === b.s && a.v === b.v && a.a === b.a; + } + + // from http://www.rapidtables.com/convert/color/rgb-to-hsv.htm + static fromRGBA(rgba: RGBA): HSVA { + const r = rgba.r / 255; + const g = rgba.g / 255; + const b = rgba.b / 255; + const cmax = Math.max(r, g, b); + const cmin = Math.min(r, g, b); + const delta = cmax - cmin; + const s = cmax === 0 ? 0 : (delta / cmax); + let m: number; + + if (delta === 0) { + m = 0; + } else if (cmax === r) { + m = ((((g - b) / delta) % 6) + 6) % 6; + } else if (cmax === g) { + m = ((b - r) / delta) + 2; + } else { + m = ((r - g) / delta) + 4; + } + + return new HSVA(Math.round(m * 60), s, cmax, rgba.a); + } + + // from http://www.rapidtables.com/convert/color/hsv-to-rgb.htm + static toRGBA(hsva: HSVA): RGBA { + const { h, s, v, a } = hsva; + const c = v * s; + const x = c * (1 - Math.abs((h / 60) % 2 - 1)); + const m = v - c; + let [r, g, b] = [0, 0, 0]; + + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else if (h < 360) { + r = c; + b = x; + } + + r = Math.round((r + m) * 255); + g = Math.round((g + m) * 255); + b = Math.round((b + m) * 255); + + return new RGBA(r, g, b, a); + } +} + +export class Color { + + static fromHex(hex: string): Color { + return Color.Format.CSS.parseHex(hex) || Color.RED; + } + + readonly rgba: RGBA; + private _hsla: HSLA; + get hsla(): HSLA { + if (this._hsla) { + return this._hsla; + } else { + return HSLA.fromRGBA(this.rgba); + } + } + + private _hsva: HSVA; + get hsva(): HSVA { + if (this._hsva) { + return this._hsva; + } + return HSVA.fromRGBA(this.rgba); + } + + constructor(arg: RGBA | HSLA | HSVA) { + if (!arg) { + throw new Error('Color needs a value'); + } else if (arg instanceof RGBA) { + this.rgba = arg; + } else if (arg instanceof HSLA) { + this._hsla = arg; + this.rgba = HSLA.toRGBA(arg); + } else if (arg instanceof HSVA) { + this._hsva = arg; + this.rgba = HSVA.toRGBA(arg); + } else { + throw new Error('Invalid color ctor argument'); + } + } + + equals(other: Color): boolean { + return !!other && RGBA.equals(this.rgba, other.rgba) && HSLA.equals(this.hsla, other.hsla) && HSVA.equals(this.hsva, other.hsva); + } + + /** + * http://www.w3.org/TR/WCAG20/#relativeluminancedef + * Returns the number in the set [0, 1]. O => Darkest Black. 1 => Lightest white. + */ + getRelativeLuminance(): number { + const R = Color._relativeLuminanceForComponent(this.rgba.r); + const G = Color._relativeLuminanceForComponent(this.rgba.g); + const B = Color._relativeLuminanceForComponent(this.rgba.b); + const luminance = 0.2126 * R + 0.7152 * G + 0.0722 * B; + + return roundFloat(luminance, 4); + } + + private static _relativeLuminanceForComponent(color: number): number { + const c = color / 255; + return (c <= 0.03928) ? c / 12.92 : Math.pow(((c + 0.055) / 1.055), 2.4); + } + + /** + * http://www.w3.org/TR/WCAG20/#contrast-ratiodef + * Returns the contrast ration number in the set [1, 21]. + */ + getContrastRatio(another: Color): number { + const lum1 = this.getRelativeLuminance(); + const lum2 = another.getRelativeLuminance(); + return lum1 > lum2 ? (lum1 + 0.05) / (lum2 + 0.05) : (lum2 + 0.05) / (lum1 + 0.05); + } + + /** + * http://24ways.org/2010/calculating-color-contrast + * Return 'true' if darker color otherwise 'false' + */ + isDarker(): boolean { + const yiq = (this.rgba.r * 299 + this.rgba.g * 587 + this.rgba.b * 114) / 1000; + return yiq < 128; + } + + /** + * http://24ways.org/2010/calculating-color-contrast + * Return 'true' if lighter color otherwise 'false' + */ + isLighter(): boolean { + const yiq = (this.rgba.r * 299 + this.rgba.g * 587 + this.rgba.b * 114) / 1000; + return yiq >= 128; + } + + isLighterThan(another: Color): boolean { + const lum1 = this.getRelativeLuminance(); + const lum2 = another.getRelativeLuminance(); + return lum1 > lum2; + } + + isDarkerThan(another: Color): boolean { + const lum1 = this.getRelativeLuminance(); + const lum2 = another.getRelativeLuminance(); + return lum1 < lum2; + } + + lighten(factor: number): Color { + return new Color(new HSLA(this.hsla.h, this.hsla.s, this.hsla.l + this.hsla.l * factor, this.hsla.a)); + } + + darken(factor: number): Color { + return new Color(new HSLA(this.hsla.h, this.hsla.s, this.hsla.l - this.hsla.l * factor, this.hsla.a)); + } + + transparent(factor: number): Color { + const { r, g, b, a } = this.rgba; + return new Color(new RGBA(r, g, b, a * factor)); + } + + isTransparent(): boolean { + return this.rgba.a === 0; + } + + isOpaque(): boolean { + return this.rgba.a === 1; + } + + opposite(): Color { + return new Color(new RGBA(255 - this.rgba.r, 255 - this.rgba.g, 255 - this.rgba.b, this.rgba.a)); + } + + blend(c: Color): Color { + const rgba = c.rgba; + + // Convert to 0..1 opacity + const thisA = this.rgba.a; + const colorA = rgba.a; + + let a = thisA + colorA * (1 - thisA); + if (a < 1e-6) { + return Color.TRANSPARENT; + } + + const r = this.rgba.r * thisA / a + rgba.r * colorA * (1 - thisA) / a; + const g = this.rgba.g * thisA / a + rgba.g * colorA * (1 - thisA) / a; + const b = this.rgba.b * thisA / a + rgba.b * colorA * (1 - thisA) / a; + + return new Color(new RGBA(r, g, b, a)); + } + + flatten(...backgrounds: Color[]): Color { + const background = backgrounds.reduceRight((accumulator, color) => { + return Color._flatten(color, accumulator); + }); + return Color._flatten(this, background); + } + + private static _flatten(foreground: Color, background: Color) { + const backgroundAlpha = 1 - foreground.rgba.a; + return new Color(new RGBA( + backgroundAlpha * background.rgba.r + foreground.rgba.a * foreground.rgba.r, + backgroundAlpha * background.rgba.g + foreground.rgba.a * foreground.rgba.g, + backgroundAlpha * background.rgba.b + foreground.rgba.a * foreground.rgba.b + )); + } + + toString(): string { + return '' + Color.Format.CSS.format(this); + } + + static getLighterColor(of: Color, relative: Color, factor?: number): Color { + if (of.isLighterThan(relative)) { + return of; + } + factor = factor ? factor : 0.5; + const lum1 = of.getRelativeLuminance(); + const lum2 = relative.getRelativeLuminance(); + factor = factor * (lum2 - lum1) / lum2; + return of.lighten(factor); + } + + static getDarkerColor(of: Color, relative: Color, factor?: number): Color { + if (of.isDarkerThan(relative)) { + return of; + } + factor = factor ? factor : 0.5; + const lum1 = of.getRelativeLuminance(); + const lum2 = relative.getRelativeLuminance(); + factor = factor * (lum1 - lum2) / lum1; + return of.darken(factor); + } + + static readonly WHITE = new Color(new RGBA(255, 255, 255, 1)); + static readonly BLACK = new Color(new RGBA(0, 0, 0, 1)); + static readonly RED = new Color(new RGBA(255, 0, 0, 1)); + static readonly BLUE = new Color(new RGBA(0, 0, 255, 1)); + static readonly GREEN = new Color(new RGBA(0, 255, 0, 1)); + static readonly CYAN = new Color(new RGBA(0, 255, 255, 1)); + static readonly LIGHTGREY = new Color(new RGBA(211, 211, 211, 1)); + static readonly TRANSPARENT = new Color(new RGBA(0, 0, 0, 0)); +} + +export namespace Color { + export namespace Format { + export namespace CSS { + + export function formatRGB(color: Color): string { + if (color.rgba.a === 1) { + return `rgb(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b})`; + } + + return Color.Format.CSS.formatRGBA(color); + } + + export function formatRGBA(color: Color): string { + return `rgba(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b}, ${+(color.rgba.a).toFixed(2)})`; + } + + export function formatHSL(color: Color): string { + if (color.hsla.a === 1) { + return `hsl(${color.hsla.h}, ${(color.hsla.s * 100).toFixed(2)}%, ${(color.hsla.l * 100).toFixed(2)}%)`; + } + + return Color.Format.CSS.formatHSLA(color); + } + + export function formatHSLA(color: Color): string { + return `hsla(${color.hsla.h}, ${(color.hsla.s * 100).toFixed(2)}%, ${(color.hsla.l * 100).toFixed(2)}%, ${color.hsla.a.toFixed(2)})`; + } + + function _toTwoDigitHex(n: number): string { + const r = n.toString(16); + return r.length !== 2 ? '0' + r : r; + } + + /** + * Formats the color as #RRGGBB + */ + export function formatHex(color: Color): string { + return `#${_toTwoDigitHex(color.rgba.r)}${_toTwoDigitHex(color.rgba.g)}${_toTwoDigitHex(color.rgba.b)}`; + } + + /** + * Formats the color as #RRGGBBAA + * If 'compact' is set, colors without transparancy will be printed as #RRGGBB + */ + export function formatHexA(color: Color, compact = false): string { + if (compact && color.rgba.a === 1) { + return Color.Format.CSS.formatHex(color); + } + + return `#${_toTwoDigitHex(color.rgba.r)}${_toTwoDigitHex(color.rgba.g)}${_toTwoDigitHex(color.rgba.b)}${_toTwoDigitHex(Math.round(color.rgba.a * 255))}`; + } + + /** + * The default format will use HEX if opaque and RGBA otherwise. + */ + export function format(color: Color): string | null { + if (!color) { + return null; + } + + if (color.isOpaque()) { + return Color.Format.CSS.formatHex(color); + } + + return Color.Format.CSS.formatRGBA(color); + } + + /** + * Converts an Hex color value to a Color. + * returns r, g, and b are contained in the set [0, 255] + * @param hex string (#RGB, #RGBA, #RRGGBB or #RRGGBBAA). + */ + export function parseHex(hex: string): Color | null { + if (!hex) { + // Invalid color + return null; + } + + const length = hex.length; + + if (length === 0) { + // Invalid color + return null; + } + + if (hex.charCodeAt(0) !== CharCode.Hash) { + // Does not begin with a # + return null; + } + + if (length === 7) { + // #RRGGBB format + const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); + const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); + const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); + return new Color(new RGBA(r, g, b, 1)); + } + + if (length === 9) { + // #RRGGBBAA format + const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); + const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); + const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); + const a = 16 * _parseHexDigit(hex.charCodeAt(7)) + _parseHexDigit(hex.charCodeAt(8)); + return new Color(new RGBA(r, g, b, a / 255)); + } + + if (length === 4) { + // #RGB format + const r = _parseHexDigit(hex.charCodeAt(1)); + const g = _parseHexDigit(hex.charCodeAt(2)); + const b = _parseHexDigit(hex.charCodeAt(3)); + return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b)); + } + + if (length === 5) { + // #RGBA format + const r = _parseHexDigit(hex.charCodeAt(1)); + const g = _parseHexDigit(hex.charCodeAt(2)); + const b = _parseHexDigit(hex.charCodeAt(3)); + const a = _parseHexDigit(hex.charCodeAt(4)); + return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b, (16 * a + a) / 255)); + } + + // Invalid color + return null; + } + + function _parseHexDigit(charCode: CharCode): number { + switch (charCode) { + case CharCode.Digit0: return 0; + case CharCode.Digit1: return 1; + case CharCode.Digit2: return 2; + case CharCode.Digit3: return 3; + case CharCode.Digit4: return 4; + case CharCode.Digit5: return 5; + case CharCode.Digit6: return 6; + case CharCode.Digit7: return 7; + case CharCode.Digit8: return 8; + case CharCode.Digit9: return 9; + case CharCode.a: return 10; + case CharCode.A: return 10; + case CharCode.b: return 11; + case CharCode.B: return 11; + case CharCode.c: return 12; + case CharCode.C: return 12; + case CharCode.d: return 13; + case CharCode.D: return 13; + case CharCode.e: return 14; + case CharCode.E: return 14; + case CharCode.f: return 15; + case CharCode.F: return 15; + } + return 0; + } + } + } +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/common/dom.ts b/src/main/custom-electron-titlebar/common/dom.ts new file mode 100644 index 0000000..5decdee --- /dev/null +++ b/src/main/custom-electron-titlebar/common/dom.ts @@ -0,0 +1,1178 @@ +/*--------------------------------------------------------------------------------------------- + * 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 '../browser/browser'; +import { domEvent } from '../browser/event'; +import { IKeyboardEvent, StandardKeyboardEvent } from '../browser/keyboardEvent'; +import { IMouseEvent, StandardMouseEvent } from '../browser/mouseEvent'; +import { TimeoutTimer } from './async'; +import { CharCode } from './charCode'; +import { Emitter, Event } from './event'; +import { Disposable, IDisposable, dispose, toDisposable } from './lifecycle'; +import * as platform from './platform'; +import { coalesce } from './arrays'; + +export function clearNode(node: HTMLElement): void { + while (node.firstChild) { + node.removeChild(node.firstChild); + } +} + +export function removeNode(node: HTMLElement): void { + if (node.parentNode) { + node.parentNode.removeChild(node); + } +} + +export function isInDOM(node: Node | null): boolean { + while (node) { + if (node === document.body) { + return true; + } + node = node.parentNode; + } + return false; +} + +interface IDomClassList { + hasClass(node: HTMLElement, className: string): boolean; + addClass(node: HTMLElement, className: string): void; + addClasses(node: HTMLElement, ...classNames: string[]): void; + removeClass(node: HTMLElement, className: string): void; + removeClasses(node: HTMLElement, ...classNames: string[]): void; + toggleClass(node: HTMLElement, className: string, shouldHaveIt?: boolean): void; +} + +const _manualClassList = new class implements IDomClassList { + + private _lastStart: number; + private _lastEnd: number; + + private _findClassName(node: HTMLElement, className: string): void { + + let classes = node.className; + if (!classes) { + this._lastStart = -1; + return; + } + + className = className.trim(); + + let classesLen = classes.length, + classLen = className.length; + + if (classLen === 0) { + this._lastStart = -1; + return; + } + + if (classesLen < classLen) { + this._lastStart = -1; + return; + } + + if (classes === className) { + this._lastStart = 0; + this._lastEnd = classesLen; + return; + } + + let idx = -1, + idxEnd: number; + + while ((idx = classes.indexOf(className, idx + 1)) >= 0) { + + idxEnd = idx + classLen; + + // a class that is followed by another class + if ((idx === 0 || classes.charCodeAt(idx - 1) === CharCode.Space) && classes.charCodeAt(idxEnd) === CharCode.Space) { + this._lastStart = idx; + this._lastEnd = idxEnd + 1; + return; + } + + // last class + if (idx > 0 && classes.charCodeAt(idx - 1) === CharCode.Space && idxEnd === classesLen) { + this._lastStart = idx - 1; + this._lastEnd = idxEnd; + return; + } + + // equal - duplicate of cmp above + if (idx === 0 && idxEnd === classesLen) { + this._lastStart = 0; + this._lastEnd = idxEnd; + return; + } + } + + this._lastStart = -1; + } + + hasClass(node: HTMLElement, className: string): boolean { + this._findClassName(node, className); + return this._lastStart !== -1; + } + + addClasses(node: HTMLElement, ...classNames: string[]): void { + classNames.forEach(nameValue => nameValue.split(' ').forEach(name => this.addClass(node, name))); + } + + addClass(node: HTMLElement, className: string): void { + if (!node.className) { // doesn't have it for sure + node.className = className; + } else { + this._findClassName(node, className); // see if it's already there + if (this._lastStart === -1) { + node.className = node.className + ' ' + className; + } + } + } + + removeClass(node: HTMLElement, className: string): void { + this._findClassName(node, className); + if (this._lastStart === -1) { + return; // Prevent styles invalidation if not necessary + } else { + node.className = node.className.substring(0, this._lastStart) + node.className.substring(this._lastEnd); + } + } + + removeClasses(node: HTMLElement, ...classNames: string[]): void { + classNames.forEach(nameValue => nameValue.split(' ').forEach(name => this.removeClass(node, name))); + } + + toggleClass(node: HTMLElement, className: string, shouldHaveIt?: boolean): void { + this._findClassName(node, className); + if (this._lastStart !== -1 && (shouldHaveIt === undefined || !shouldHaveIt)) { + this.removeClass(node, className); + } + if (this._lastStart === -1 && (shouldHaveIt === undefined || shouldHaveIt)) { + this.addClass(node, className); + } + } +}; + +const _nativeClassList = new class implements IDomClassList { + hasClass(node: HTMLElement, className: string): boolean { + return Boolean(className) && node.classList && node.classList.contains(className); + } + + addClasses(node: HTMLElement, ...classNames: string[]): void { + classNames.forEach(nameValue => nameValue.split(' ').forEach(name => this.addClass(node, name))); + } + + addClass(node: HTMLElement, className: string): void { + if (className && node.classList) { + node.classList.add(className); + } + } + + removeClass(node: HTMLElement, className: string): void { + if (className && node.classList) { + node.classList.remove(className); + } + } + + removeClasses(node: HTMLElement, ...classNames: string[]): void { + classNames.forEach(nameValue => nameValue.split(' ').forEach(name => this.removeClass(node, name))); + } + + toggleClass(node: HTMLElement, className: string, shouldHaveIt?: boolean): void { + if (node.classList) { + node.classList.toggle(className, shouldHaveIt); + } + } +}; + +// In IE11 there is only partial support for `classList` which makes us keep our +// custom implementation. Otherwise use the native implementation, see: http://caniuse.com/#search=classlist +const _classList: IDomClassList = browser.isIE ? _manualClassList : _nativeClassList; +export const hasClass: (node: HTMLElement, className: string) => boolean = _classList.hasClass.bind(_classList); +export const addClass: (node: HTMLElement, className: string) => void = _classList.addClass.bind(_classList); +export const addClasses: (node: HTMLElement, ...classNames: string[]) => void = _classList.addClasses.bind(_classList); +export const removeClass: (node: HTMLElement, className: string) => void = _classList.removeClass.bind(_classList); +export const removeClasses: (node: HTMLElement, ...classNames: string[]) => void = _classList.removeClasses.bind(_classList); +export const toggleClass: (node: HTMLElement, className: string, shouldHaveIt?: boolean) => void = _classList.toggleClass.bind(_classList); + +class DomListener implements IDisposable { + + private _handler: (e: any) => void; + private _node: Element | Window | Document; + private readonly _type: string; + private readonly _useCapture: boolean; + + constructor(node: Element | Window | Document, type: string, handler: (e: any) => void, useCapture?: boolean) { + this._node = node; + this._type = type; + this._handler = handler; + this._useCapture = (useCapture || false); + this._node.addEventListener(this._type, this._handler, this._useCapture); + } + + public dispose(): void { + if (!this._handler) { + // Already disposed + return; + } + + this._node.removeEventListener(this._type, this._handler, this._useCapture); + + // Prevent leakers from holding on to the dom or handler func + this._node = null!; + this._handler = null!; + } +} + +export function addDisposableListener(node: Element | Window | Document, type: K, handler: (event: GlobalEventHandlersEventMap[K]) => void, useCapture?: boolean): IDisposable; +export function addDisposableListener(node: Element | Window | Document, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable; +export function addDisposableListener(node: Element | Window | Document, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable { + return new DomListener(node, type, handler, useCapture); +} + +export interface IAddStandardDisposableListenerSignature { + (node: HTMLElement, type: 'click', handler: (event: IMouseEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'mousedown', handler: (event: IMouseEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'keydown', handler: (event: IKeyboardEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'keypress', handler: (event: IKeyboardEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: 'keyup', handler: (event: IKeyboardEvent) => void, useCapture?: boolean): IDisposable; + (node: HTMLElement, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable; +} +function _wrapAsStandardMouseEvent(handler: (e: IMouseEvent) => void): (e: MouseEvent) => void { + return function (e: MouseEvent) { + return handler(new StandardMouseEvent(e)); + }; +} +function _wrapAsStandardKeyboardEvent(handler: (e: IKeyboardEvent) => void): (e: KeyboardEvent) => void { + return function (e: KeyboardEvent) { + return handler(new StandardKeyboardEvent(e)); + }; +} +export let addStandardDisposableListener: IAddStandardDisposableListenerSignature = function addStandardDisposableListener(node: HTMLElement, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable { + let wrapHandler = handler; + + if (type === 'click' || type === 'mousedown') { + wrapHandler = _wrapAsStandardMouseEvent(handler); + } else if (type === 'keydown' || type === 'keypress' || type === 'keyup') { + wrapHandler = _wrapAsStandardKeyboardEvent(handler); + } + + return addDisposableListener(node, type, wrapHandler, useCapture); +}; + +export function addDisposableNonBubblingMouseOutListener(node: Element, handler: (event: MouseEvent) => void): IDisposable { + return addDisposableListener(node, 'mouseout', (e: MouseEvent) => { + // Mouse out bubbles, so this is an attempt to ignore faux mouse outs coming from children elements + let toElement: Node | null = (e.relatedTarget || e.target); + while (toElement && toElement !== node) { + toElement = toElement.parentNode; + } + if (toElement === node) { + return; + } + + handler(e); + }); +} + +interface IRequestAnimationFrame { + (callback: (time: number) => void): number; +} +let _animationFrame: IRequestAnimationFrame | null = null; +function doRequestAnimationFrame(callback: (time: number) => void): number { + if (!_animationFrame) { + const emulatedRequestAnimationFrame = (callback: (time: number) => void): any => { + return setTimeout(() => callback(new Date().getTime()), 0); + }; + _animationFrame = ( + self.requestAnimationFrame + || (self).msRequestAnimationFrame + || (self).webkitRequestAnimationFrame + || (self).mozRequestAnimationFrame + || (self).oRequestAnimationFrame + || emulatedRequestAnimationFrame + ); + } + return _animationFrame.call(self, callback); +} + +/** + * Schedule a callback to be run at the next animation frame. + * This allows multiple parties to register callbacks that should run at the next animation frame. + * If currently in an animation frame, `runner` will be executed immediately. + * @return token that can be used to cancel the scheduled runner (only if `runner` was not executed immediately). + */ +export let runAtThisOrScheduleAtNextAnimationFrame: (runner: () => void, priority?: number) => IDisposable; +/** + * Schedule a callback to be run at the next animation frame. + * This allows multiple parties to register callbacks that should run at the next animation frame. + * If currently in an animation frame, `runner` will be executed at the next animation frame. + * @return token that can be used to cancel the scheduled runner. + */ +export let scheduleAtNextAnimationFrame: (runner: () => void, priority?: number) => IDisposable; + +class AnimationFrameQueueItem implements IDisposable { + + private _runner: () => void; + public priority: number; + private _canceled: boolean; + + constructor(runner: () => void, priority: number = 0) { + this._runner = runner; + this.priority = priority; + this._canceled = false; + } + + public dispose(): void { + this._canceled = true; + } + + public execute(): void { + if (this._canceled) { + return; + } + + try { + this._runner(); + } catch (e) { + console.error(e); + } + } + + // Sort by priority (largest to lowest) + public static sort(a: AnimationFrameQueueItem, b: AnimationFrameQueueItem): number { + return b.priority - a.priority; + } +} + +(function () { + /** + * The runners scheduled at the next animation frame + */ + let NEXT_QUEUE: AnimationFrameQueueItem[] = []; + /** + * The runners scheduled at the current animation frame + */ + let CURRENT_QUEUE: AnimationFrameQueueItem[] | null = null; + /** + * A flag to keep track if the native requestAnimationFrame was already called + */ + let animFrameRequested = false; + /** + * A flag to indicate if currently handling a native requestAnimationFrame callback + */ + let inAnimationFrameRunner = false; + + let animationFrameRunner = () => { + animFrameRequested = false; + + CURRENT_QUEUE = NEXT_QUEUE; + NEXT_QUEUE = []; + + inAnimationFrameRunner = true; + while (CURRENT_QUEUE.length > 0) { + CURRENT_QUEUE.sort(AnimationFrameQueueItem.sort); + let top = CURRENT_QUEUE.shift()!; + top.execute(); + } + inAnimationFrameRunner = false; + }; + + scheduleAtNextAnimationFrame = (runner: () => void, priority: number = 0) => { + let item = new AnimationFrameQueueItem(runner, priority); + NEXT_QUEUE.push(item); + + if (!animFrameRequested) { + animFrameRequested = true; + doRequestAnimationFrame(animationFrameRunner); + } + + return item; + }; + + runAtThisOrScheduleAtNextAnimationFrame = (runner: () => void, priority?: number) => { + if (inAnimationFrameRunner) { + let item = new AnimationFrameQueueItem(runner, priority); + CURRENT_QUEUE!.push(item); + return item; + } else { + return scheduleAtNextAnimationFrame(runner, priority); + } + }; +})(); + +export function measure(callback: () => void): IDisposable { + return scheduleAtNextAnimationFrame(callback, 10000 /* must be early */); +} + +export function modify(callback: () => void): IDisposable { + return scheduleAtNextAnimationFrame(callback, -10000 /* must be late */); +} + +/** + * Add a throttled listener. `handler` is fired at most every 16ms or with the next animation frame (if browser supports it). + */ +export interface IEventMerger { + (lastEvent: R | null, currentEvent: E): R; +} + +export interface DOMEvent { + preventDefault(): void; + stopPropagation(): void; +} + +const MINIMUM_TIME_MS = 16; +const DEFAULT_EVENT_MERGER: IEventMerger = function (lastEvent: DOMEvent, currentEvent: DOMEvent) { + return currentEvent; +}; + +class TimeoutThrottledDomListener extends Disposable { + + constructor(node: any, type: string, handler: (event: R) => void, eventMerger: IEventMerger = DEFAULT_EVENT_MERGER, minimumTimeMs: number = MINIMUM_TIME_MS) { + super(); + + let lastEvent: R | null = null; + let lastHandlerTime = 0; + let timeout = this._register(new TimeoutTimer()); + + let invokeHandler = () => { + lastHandlerTime = (new Date()).getTime(); + handler(lastEvent); + lastEvent = null; + }; + + this._register(addDisposableListener(node, type, (e) => { + + lastEvent = eventMerger(lastEvent, e); + let elapsedTime = (new Date()).getTime() - lastHandlerTime; + + if (elapsedTime >= minimumTimeMs) { + timeout.cancel(); + invokeHandler(); + } else { + timeout.setIfNotSet(invokeHandler, minimumTimeMs - elapsedTime); + } + })); + } +} + +export function addDisposableThrottledListener(node: any, type: string, handler: (event: R) => void, eventMerger?: IEventMerger, minimumTimeMs?: number): IDisposable { + return new TimeoutThrottledDomListener(node, type, handler, eventMerger, minimumTimeMs); +} + +export function getComputedStyle(el: HTMLElement): CSSStyleDeclaration { + return document.defaultView!.getComputedStyle(el, null); +} + +// Adapted from WinJS +// Converts a CSS positioning string for the specified element to pixels. +const convertToPixels: (element: HTMLElement, value: string) => number = (function () { + return function (element: HTMLElement, value: string): number { + return parseFloat(value) || 0; + }; +})(); + +function getDimension(element: HTMLElement, cssPropertyName: string, jsPropertyName: string): number { + let computedStyle: CSSStyleDeclaration = getComputedStyle(element); + let value = '0'; + if (computedStyle) { + if (computedStyle.getPropertyValue) { + value = computedStyle.getPropertyValue(cssPropertyName); + } else { + // IE8 + value = (computedStyle).getAttribute(jsPropertyName); + } + } + return convertToPixels(element, value); +} + +export function getClientArea(element: HTMLElement): Dimension { + + // Try with DOM clientWidth / clientHeight + if (element !== document.body) { + return new Dimension(element.clientWidth, element.clientHeight); + } + + // Try innerWidth / innerHeight + if (window.innerWidth && window.innerHeight) { + return new Dimension(window.innerWidth, window.innerHeight); + } + + // Try with document.body.clientWidth / document.body.clientHeight + if (document.body && document.body.clientWidth && document.body.clientHeight) { + return new Dimension(document.body.clientWidth, document.body.clientHeight); + } + + // Try with document.documentElement.clientWidth / document.documentElement.clientHeight + if (document.documentElement && document.documentElement.clientWidth && document.documentElement.clientHeight) { + return new Dimension(document.documentElement.clientWidth, document.documentElement.clientHeight); + } + + throw new Error('Unable to figure out browser width and height'); +} + +const sizeUtils = { + + getBorderLeftWidth: function (element: HTMLElement): number { + return getDimension(element, 'border-left-width', 'borderLeftWidth'); + }, + getBorderRightWidth: function (element: HTMLElement): number { + return getDimension(element, 'border-right-width', 'borderRightWidth'); + }, + getBorderTopWidth: function (element: HTMLElement): number { + return getDimension(element, 'border-top-width', 'borderTopWidth'); + }, + getBorderBottomWidth: function (element: HTMLElement): number { + return getDimension(element, 'border-bottom-width', 'borderBottomWidth'); + }, + + getPaddingLeft: function (element: HTMLElement): number { + return getDimension(element, 'padding-left', 'paddingLeft'); + }, + getPaddingRight: function (element: HTMLElement): number { + return getDimension(element, 'padding-right', 'paddingRight'); + }, + getPaddingTop: function (element: HTMLElement): number { + return getDimension(element, 'padding-top', 'paddingTop'); + }, + getPaddingBottom: function (element: HTMLElement): number { + return getDimension(element, 'padding-bottom', 'paddingBottom'); + }, + + getMarginLeft: function (element: HTMLElement): number { + return getDimension(element, 'margin-left', 'marginLeft'); + }, + getMarginTop: function (element: HTMLElement): number { + return getDimension(element, 'margin-top', 'marginTop'); + }, + getMarginRight: function (element: HTMLElement): number { + return getDimension(element, 'margin-right', 'marginRight'); + }, + getMarginBottom: function (element: HTMLElement): number { + return getDimension(element, 'margin-bottom', 'marginBottom'); + }, + __commaSentinel: false +}; + +// ---------------------------------------------------------------------------------------- +// Position & Dimension + +export class Dimension { + public width: number; + public height: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + } + + static equals(a: Dimension | undefined, b: Dimension | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return a.width === b.width && a.height === b.height; + } +} + +export function getTopLeftOffset(element: HTMLElement): { left: number; top: number; } { + // Adapted from WinJS.Utilities.getPosition + // and added borders to the mix + + let offsetParent = element.offsetParent, top = element.offsetTop, left = element.offsetLeft; + + while ((element = element.parentNode) !== null && element !== document.body && element !== document.documentElement) { + top -= element.scrollTop; + let c = getComputedStyle(element); + if (c) { + left -= c.direction !== 'rtl' ? element.scrollLeft : -element.scrollLeft; + } + + if (element === offsetParent) { + left += sizeUtils.getBorderLeftWidth(element); + top += sizeUtils.getBorderTopWidth(element); + top += element.offsetTop; + left += element.offsetLeft; + offsetParent = element.offsetParent; + } + } + + return { + left: left, + top: top + }; +} + +export interface IDomNodePagePosition { + left: number; + top: number; + width: number; + height: number; +} + +export function size(element: HTMLElement, width: number, height: number): void { + if (typeof width === 'number') { + element.style.width = `${width}px`; + } + + if (typeof height === 'number') { + element.style.height = `${height}px`; + } +} + +export function position(element: HTMLElement, top: number, right?: number, bottom?: number, left?: number, position: string = 'absolute'): void { + if (typeof top === 'number') { + element.style.top = `${top}px`; + } + + if (typeof right === 'number') { + element.style.right = `${right}px`; + } + + if (typeof bottom === 'number') { + element.style.bottom = `${bottom}px`; + } + + if (typeof left === 'number') { + element.style.left = `${left}px`; + } + + element.style.position = position; +} + +/** + * Returns the position of a dom node relative to the entire page. + */ +export function getDomNodePagePosition(domNode: HTMLElement): IDomNodePagePosition { + let bb = domNode.getBoundingClientRect(); + return { + left: bb.left + StandardWindow.scrollX, + top: bb.top + StandardWindow.scrollY, + width: bb.width, + height: bb.height + }; +} + +export interface IStandardWindow { + readonly scrollX: number; + readonly scrollY: number; +} + +export const StandardWindow: IStandardWindow = new class implements IStandardWindow { + get scrollX(): number { + if (typeof window.scrollX === 'number') { + // modern browsers + return window.scrollX; + } else { + return document.body.scrollLeft + document.documentElement!.scrollLeft; + } + } + + get scrollY(): number { + if (typeof window.scrollY === 'number') { + // modern browsers + return window.scrollY; + } else { + return document.body.scrollTop + document.documentElement!.scrollTop; + } + } +}; + +// Adapted from WinJS +// Gets the width of the element, including margins. +export function getTotalWidth(element: HTMLElement): number { + let margin = sizeUtils.getMarginLeft(element) + sizeUtils.getMarginRight(element); + return element.offsetWidth + margin; +} + +export function getContentWidth(element: HTMLElement): number { + let border = sizeUtils.getBorderLeftWidth(element) + sizeUtils.getBorderRightWidth(element); + let padding = sizeUtils.getPaddingLeft(element) + sizeUtils.getPaddingRight(element); + return element.offsetWidth - border - padding; +} + +export function getTotalScrollWidth(element: HTMLElement): number { + let margin = sizeUtils.getMarginLeft(element) + sizeUtils.getMarginRight(element); + return element.scrollWidth + margin; +} + +// Adapted from WinJS +// Gets the height of the content of the specified element. The content height does not include borders or padding. +export function getContentHeight(element: HTMLElement): number { + let border = sizeUtils.getBorderTopWidth(element) + sizeUtils.getBorderBottomWidth(element); + let padding = sizeUtils.getPaddingTop(element) + sizeUtils.getPaddingBottom(element); + return element.offsetHeight - border - padding; +} + +// Adapted from WinJS +// Gets the height of the element, including its margins. +export function getTotalHeight(element: HTMLElement): number { + let margin = sizeUtils.getMarginTop(element) + sizeUtils.getMarginBottom(element); + return element.offsetHeight + margin; +} + +// Gets the left coordinate of the specified element relative to the specified parent. +function getRelativeLeft(element: HTMLElement, parent: HTMLElement): number { + if (element === null) { + return 0; + } + + let elementPosition = getTopLeftOffset(element); + let parentPosition = getTopLeftOffset(parent); + return elementPosition.left - parentPosition.left; +} + +export function getLargestChildWidth(parent: HTMLElement, children: HTMLElement[]): number { + let childWidths = children.map((child) => { + return Math.max(getTotalScrollWidth(child), getTotalWidth(child)) + getRelativeLeft(child, parent) || 0; + }); + let maxWidth = Math.max(...childWidths); + return maxWidth; +} + +// ---------------------------------------------------------------------------------------- + +export function isAncestor(testChild: Node | null, testAncestor: Node | null): boolean { + while (testChild) { + if (testChild === testAncestor) { + return true; + } + testChild = testChild.parentNode; + } + + return false; +} + +export function findParentWithClass(node: HTMLElement, clazz: string, stopAtClazzOrNode?: string | HTMLElement): HTMLElement | null { + while (node) { + if (hasClass(node, clazz)) { + return node; + } + + if (stopAtClazzOrNode) { + if (typeof stopAtClazzOrNode === 'string') { + if (hasClass(node, stopAtClazzOrNode)) { + return null; + } + } else { + if (node === stopAtClazzOrNode) { + return null; + } + } + } + + node = node.parentNode; + } + + return null; +} + +export function createStyleSheet(container: HTMLElement = document.getElementsByTagName('head')[0]): HTMLStyleElement { + let style = document.createElement('style'); + style.type = 'text/css'; + style.media = 'screen'; + container.appendChild(style); + return style; +} + +let _sharedStyleSheet: HTMLStyleElement | null = null; +function getSharedStyleSheet(): HTMLStyleElement { + if (!_sharedStyleSheet) { + _sharedStyleSheet = createStyleSheet(); + } + return _sharedStyleSheet; +} + +function getDynamicStyleSheetRules(style: any) { + if (style && style.sheet && style.sheet.rules) { + // Chrome, IE + return style.sheet.rules; + } + if (style && style.sheet && style.sheet.cssRules) { + // FF + return style.sheet.cssRules; + } + return []; +} + +export function createCSSRule(selector: string, cssText: string, style: HTMLStyleElement = getSharedStyleSheet()): void { + if (!style || !cssText) { + return; + } + + (style.sheet).insertRule(selector + '{' + cssText + '}', 0); +} + +export function removeCSSRulesContainingSelector(ruleName: string, style: HTMLStyleElement = getSharedStyleSheet()): void { + if (!style) { + return; + } + + let rules = getDynamicStyleSheetRules(style); + let toDelete: number[] = []; + for (let i = 0; i < rules.length; i++) { + let rule = rules[i]; + if (rule.selectorText.indexOf(ruleName) !== -1) { + toDelete.push(i); + } + } + + for (let i = toDelete.length - 1; i >= 0; i--) { + (style.sheet).deleteRule(toDelete[i]); + } +} + +export function isHTMLElement(o: any): o is HTMLElement { + if (typeof HTMLElement === 'object') { + return o instanceof HTMLElement; + } + return o && typeof o === 'object' && o.nodeType === 1 && typeof o.nodeName === 'string'; +} + +export const EventType = { + // Window + MINIMIZE: 'minimize' as 'minimize', + MAXIMIZE: 'maximize' as 'maximize', + UNMAXIMIZE: 'unmaximize' as 'unmaximize', + ENTER_FULLSCREEN: 'enter-full-screen' as 'enter-full-screen', + LEAVE_FULLSCREEN: 'leave-full-screen' as 'leave-full-screen', + // Mouse + CLICK: 'click' as 'click', + DBLCLICK: 'dblclick' as 'dblclick', + MOUSE_UP: 'mouseup' as 'mouseup', + MOUSE_DOWN: 'mousedown' as 'mousedown', + MOUSE_OVER: 'mouseover' as 'mouseover', + MOUSE_MOVE: 'mousemove' as 'mousemove', + MOUSE_OUT: 'mouseout' as 'mouseout', + MOUSE_ENTER: 'mouseenter' as 'mouseenter', + MOUSE_LEAVE: 'mouseleave' as 'mouseleave', + CONTEXT_MENU: 'contextmenu' as 'contextmenu', + WHEEL: 'wheel' as 'wheel', + // Keyboard + KEY_DOWN: 'keydown' as 'keydown', + KEY_PRESS: 'keypress' as 'keypress', + KEY_UP: 'keyup' as 'keyup', + // HTML Document + LOAD: 'load' as 'load', + UNLOAD: 'unload' as 'unload', + ABORT: 'abort' as 'abort', + ERROR: 'error' as 'error', + RESIZE: 'resize' as 'resize', + SCROLL: 'scroll' as 'scroll', + // Form + SELECT: 'select' as 'select', + CHANGE: 'change' as 'change', + SUBMIT: 'submit' as 'submit', + RESET: 'reset' as 'reset', + FOCUS: 'focus' as 'focus', + FOCUS_IN: 'focusin' as 'focusin', + FOCUS_OUT: 'focusout' as 'focusout', + BLUR: 'blur' as 'blur', + INPUT: 'input' as 'input', + // Local Storage + STORAGE: 'storage' as 'storage', + // Drag + DRAG_START: 'dragstart' as 'dragstart', + DRAG: 'drag' as 'drag', + DRAG_ENTER: 'dragenter' as 'dragenter', + DRAG_LEAVE: 'dragleave' as 'dragleave', + DRAG_OVER: 'dragover' as 'dragover', + DROP: 'drop' as 'drop', + DRAG_END: 'dragend' as 'dragend', + // Animation + ANIMATION_START: browser.isWebKit ? 'webkitAnimationStart' : 'animationstart', + ANIMATION_END: browser.isWebKit ? 'webkitAnimationEnd' : 'animationend', + ANIMATION_ITERATION: browser.isWebKit ? 'webkitAnimationIteration' : 'animationiteration' +}; + +export interface EventLike { + preventDefault(): void; + stopPropagation(): void; +} + +export const EventHelper = { + stop: function (e: EventLike, cancelBubble?: boolean) { + if (e.preventDefault) { + e.preventDefault(); + } else { + // IE8 + (e).returnValue = false; + } + + if (cancelBubble) { + if (e.stopPropagation) { + e.stopPropagation(); + } else { + // IE8 + (e).cancelBubble = true; + } + } + } +}; + +export interface IFocusTracker { + onDidFocus: Event; + onDidBlur: Event; + dispose(): void; +} + +export function saveParentsScrollTop(node: Element): number[] { + let r: number[] = []; + for (let i = 0; node && node.nodeType === node.ELEMENT_NODE; i++) { + r[i] = node.scrollTop; + node = node.parentNode; + } + return r; +} + +export function restoreParentsScrollTop(node: Element, state: number[]): void { + for (let i = 0; node && node.nodeType === node.ELEMENT_NODE; i++) { + if (node.scrollTop !== state[i]) { + node.scrollTop = state[i]; + } + node = node.parentNode; + } +} + +class FocusTracker implements IFocusTracker { + + private _onDidFocus = new Emitter(); + readonly onDidFocus: Event = this._onDidFocus.event; + + private _onDidBlur = new Emitter(); + readonly onDidBlur: Event = this._onDidBlur.event; + + private disposables: IDisposable[] = []; + + constructor(element: HTMLElement | Window) { + let hasFocus = isAncestor(document.activeElement, element); + let loosingFocus = false; + + let onFocus = () => { + loosingFocus = false; + if (!hasFocus) { + hasFocus = true; + this._onDidFocus.fire(); + } + }; + + let onBlur = () => { + if (hasFocus) { + loosingFocus = true; + window.setTimeout(() => { + if (loosingFocus) { + loosingFocus = false; + hasFocus = false; + this._onDidBlur.fire(); + } + }, 0); + } + }; + + domEvent(element, EventType.FOCUS, true)(onFocus, null, this.disposables); + domEvent(element, EventType.BLUR, true)(onBlur, null, this.disposables); + } + + dispose(): void { + this.disposables = dispose(this.disposables); + this._onDidFocus.dispose(); + this._onDidBlur.dispose(); + } +} + +export function trackFocus(element: HTMLElement | Window): IFocusTracker { + return new FocusTracker(element); +} + +export function append(parent: HTMLElement, ...children: T[]): T { + children.forEach(child => parent.appendChild(child)); + return children[children.length - 1]; +} + +export function prepend(parent: HTMLElement, child: T): T { + parent.insertBefore(child, parent.firstChild); + return child; +} + +const SELECTOR_REGEX = /([\w\-]+)?(#([\w\-]+))?((.([\w\-]+))*)/; + +export function $(description: string, attrs?: { [key: string]: any; }, ...children: Array): T { + let match = SELECTOR_REGEX.exec(description); + + if (!match) { + throw new Error('Bad use of emmet'); + } + + let result = document.createElement(match[1] || 'div'); + + if (match[3]) { + result.id = match[3]; + } + if (match[4]) { + result.className = match[4].replace(/\./g, ' ').trim(); + } + + attrs = attrs || {}; + Object.keys(attrs).forEach(name => { + const value = attrs![name]; + if (/^on\w+$/.test(name)) { + (result)[name] = value; + } else if (name === 'selected') { + if (value) { + result.setAttribute(name, 'true'); + } + + } else { + result.setAttribute(name, value); + } + }); + + coalesce(children) + .forEach(child => { + if (child instanceof Node) { + result.appendChild(child); + } else { + result.appendChild(document.createTextNode(child as string)); + } + }); + + return result as T; +} + +export function join(nodes: Node[], separator: Node | string): Node[] { + const result: Node[] = []; + + nodes.forEach((node, index) => { + if (index > 0) { + if (separator instanceof Node) { + result.push(separator.cloneNode()); + } else { + result.push(document.createTextNode(separator)); + } + } + + result.push(node); + }); + + return result; +} + +export function show(...elements: HTMLElement[]): void { + for (let element of elements) { + if (element) { + element.style.display = ''; + element.removeAttribute('aria-hidden'); + } + } +} + +export function hide(...elements: HTMLElement[]): void { + for (let element of elements) { + if (element) { + element.style.display = 'none'; + element.setAttribute('aria-hidden', 'true'); + } + } +} + +function findParentWithAttribute(node: Node | null, attribute: string): HTMLElement | null { + while (node) { + if (node instanceof HTMLElement && node.hasAttribute(attribute)) { + return node; + } + + node = node.parentNode; + } + + return null; +} + +export function removeTabIndexAndUpdateFocus(node: HTMLElement): void { + if (!node || !node.hasAttribute('tabIndex')) { + return; + } + + // If we are the currently focused element and tabIndex is removed, + // standard DOM behavior is to move focus to the element. We + // typically never want that, rather put focus to the closest element + // in the hierarchy of the parent DOM nodes. + if (document.activeElement === node) { + let parentFocusable = findParentWithAttribute(node.parentElement, 'tabIndex'); + if (parentFocusable) { + parentFocusable.focus(); + } + } + + node.removeAttribute('tabindex'); +} + +export function getElementsByTagName(tag: string): HTMLElement[] { + return Array.prototype.slice.call(document.getElementsByTagName(tag), 0); +} + +export function finalHandler(fn: (event: T) => any): (event: T) => any { + return e => { + e.preventDefault(); + e.stopPropagation(); + fn(e); + }; +} + +export function domContentLoaded(): Promise { + return new Promise(resolve => { + const readyState = document.readyState; + if (readyState === 'complete' || (document && document.body !== null)) { + platform.setImmediate(resolve); + } else { + window.addEventListener('DOMContentLoaded', resolve, false); + } + }); +} + +/** + * Find a value usable for a dom node size such that the likelihood that it would be + * displayed with constant screen pixels size is as high as possible. + * + * e.g. We would desire for the cursors to be 2px (CSS px) wide. Under a devicePixelRatio + * of 1.25, the cursor will be 2.5 screen pixels wide. Depending on how the dom node aligns/"snaps" + * with the screen pixels, it will sometimes be rendered with 2 screen pixels, and sometimes with 3 screen pixels. + */ +export function computeScreenAwareSize(cssPx: number): number { + const screenPx = window.devicePixelRatio * cssPx; + return Math.max(1, Math.floor(screenPx)) / window.devicePixelRatio; +} + +/** + * See https://github.com/Microsoft/monaco-editor/issues/601 + * To protect against malicious code in the linked site, particularly phishing attempts, + * the window.opener should be set to null to prevent the linked site from having access + * to change the location of the current page. + * See https://mathiasbynens.github.io/rel-noopener/ + */ +export function windowOpenNoOpener(url: string): void { + if (platform.isNative || browser.isEdgeWebView) { + // In VSCode, window.open() always returns null... + // The same is true for a WebView (see https://github.com/Microsoft/monaco-editor/issues/628) + window.open(url); + } else { + let newTab = window.open(); + if (newTab) { + (newTab as any).opener = null; + newTab.location.href = url; + } + } +} + +export function animate(fn: () => void): IDisposable { + const step = () => { + fn(); + stepDisposable = scheduleAtNextAnimationFrame(step); + }; + + let stepDisposable = scheduleAtNextAnimationFrame(step); + return toDisposable(() => stepDisposable.dispose()); +} diff --git a/src/main/custom-electron-titlebar/common/event.ts b/src/main/custom-electron-titlebar/common/event.ts new file mode 100644 index 0000000..768322f --- /dev/null +++ b/src/main/custom-electron-titlebar/common/event.ts @@ -0,0 +1,722 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { combinedDisposable, Disposable, IDisposable, toDisposable } from './lifecycle'; +import { LinkedList } from './linkedList'; + +/** + * To an event a function with one or zero parameters + * can be subscribed. The event is the subscriber function itself. + */ +export interface Event { + (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[]): IDisposable; +} + +export namespace Event { + const _disposable = { dispose() { } }; + export const None: Event = function () { return _disposable; }; + + /** + * Given an event, returns another event which only fires once. + */ + export function once(event: Event): Event { + return (listener, thisArgs = null, disposables?) => { + // we need this, in case the event fires during the listener call + let didFire = false; + + const result = event(e => { + if (didFire) { + return; + } else if (result) { + result.dispose(); + } else { + didFire = true; + } + + return listener.call(thisArgs, e); + }, null, disposables); + + if (didFire) { + result.dispose(); + } + + return result; + }; + } + + /** + * Given an event and a `map` function, returns another event which maps each element + * throught the mapping function. + */ + export function map(event: Event, map: (i: I) => O): Event { + return (listener, thisArgs = null, disposables?) => event(i => listener.call(thisArgs, map(i)), null, disposables); + } + + /** + * Given an event and an `each` function, returns another identical event and calls + * the `each` function per each element. + */ + export function forEach(event: Event, each: (i: I) => void): Event { + return (listener, thisArgs = null, disposables?) => event(i => { each(i); listener.call(thisArgs, i); }, null, disposables); + } + + /** + * Given an event and a `filter` function, returns another event which emits those + * elements for which the `filter` function returns `true`. + */ + export function filter(event: Event, filter: (e: T) => boolean): Event; + export function filter(event: Event, filter: (e: T | R) => e is R): Event; + export function filter(event: Event, filter: (e: T) => boolean): Event { + return (listener, thisArgs = null, disposables?) => event(e => filter(e) && listener.call(thisArgs, e), null, disposables); + } + + /** + * Given an event, returns the same event but typed as `Event`. + */ + export function signal(event: Event): Event { + return event as Event as Event; + } + + /** + * Given a collection of events, returns a single event which emits + * whenever any of the provided events emit. + */ + export function any(...events: Event[]): Event { + return (listener, thisArgs = null, disposables?) => combinedDisposable(events.map(event => event(e => listener.call(thisArgs, e), null, disposables))); + } + + /** + * Given an event and a `merge` function, returns another event which maps each element + * and the cummulative result throught the `merge` function. Similar to `map`, but with memory. + */ + export function reduce(event: Event, merge: (last: O | undefined, event: I) => O, initial?: O): Event { + let output: O | undefined = initial; + + return map(event, e => { + output = merge(output, e); + return output; + }); + } + + /** + * Debounces the provided event, given a `merge` function. + * + * @param event The input event. + * @param merge The reducing function. + * @param delay The debouncing delay in millis. + * @param leading Whether the event should fire in the leading phase of the timeout. + * @param leakWarningThreshold The leak warning threshold override. + */ + export function debounce(event: Event, merge: (last: T, event: T) => T, delay?: number, leading?: boolean, leakWarningThreshold?: number): Event; + export function debounce(event: Event, merge: (last: O | undefined, event: I) => O, delay?: number, leading?: boolean, leakWarningThreshold?: number): Event; + export function debounce(event: Event, merge: (last: O | undefined, event: I) => O, delay: number = 100, leading = false, leakWarningThreshold?: number): Event { + + let subscription: IDisposable; + let output: O | undefined = undefined; + let handle: any = undefined; + let numDebouncedCalls = 0; + + const emitter = new Emitter({ + leakWarningThreshold, + onFirstListenerAdd() { + subscription = event(cur => { + numDebouncedCalls++; + output = merge(output, cur); + + if (leading && !handle) { + emitter.fire(output); + } + + clearTimeout(handle); + handle = setTimeout(() => { + let _output = output; + output = undefined; + handle = undefined; + if (!leading || numDebouncedCalls > 1) { + emitter.fire(_output!); + } + + numDebouncedCalls = 0; + }, delay); + }); + }, + onLastListenerRemove() { + subscription.dispose(); + } + }); + + return emitter.event; + } + + /** + * Given an event, it returns another event which fires only once and as soon as + * the input event emits. The event data is the number of millis it took for the + * event to fire. + */ + export function stopwatch(event: Event): Event { + const start = new Date().getTime(); + return map(once(event), _ => new Date().getTime() - start); + } + + /** + * Given an event, it returns another event which fires only when the event + * element changes. + */ + export function latch(event: Event): Event { + let firstCall = true; + let cache: T; + + return filter(event, value => { + let shouldEmit = firstCall || value !== cache; + firstCall = false; + cache = value; + return shouldEmit; + }); + } + + /** + * Buffers the provided event until a first listener comes + * along, at which point fire all the events at once and + * pipe the event from then on. + * + * ```typescript + * const emitter = new Emitter(); + * const event = emitter.event; + * const bufferedEvent = buffer(event); + * + * emitter.fire(1); + * emitter.fire(2); + * emitter.fire(3); + * // nothing... + * + * const listener = bufferedEvent(num => console.log(num)); + * // 1, 2, 3 + * + * emitter.fire(4); + * // 4 + * ``` + */ + export function buffer(event: Event, nextTick = false, _buffer: T[] = []): Event { + let buffer: T[] | null = _buffer.slice(); + + let listener: IDisposable | null = event(e => { + if (buffer) { + buffer.push(e); + } else { + emitter.fire(e); + } + }); + + const flush = () => { + if (buffer) { + buffer.forEach(e => emitter.fire(e)); + } + buffer = null; + }; + + const emitter = new Emitter({ + onFirstListenerAdd() { + if (!listener) { + listener = event(e => emitter.fire(e)); + } + }, + + onFirstListenerDidAdd() { + if (buffer) { + if (nextTick) { + setTimeout(flush); + } else { + flush(); + } + } + }, + + onLastListenerRemove() { + if (listener) { + listener.dispose(); + } + listener = null; + } + }); + + return emitter.event; + } + + /** + * Similar to `buffer` but it buffers indefinitely and repeats + * the buffered events to every new listener. + */ + export function echo(event: Event, nextTick = false, buffer: T[] = []): Event { + buffer = buffer.slice(); + + event(e => { + buffer.push(e); + emitter.fire(e); + }); + + const flush = (listener: (e: T) => any, thisArgs?: any) => buffer.forEach(e => listener.call(thisArgs, e)); + + const emitter = new Emitter({ + onListenerDidAdd(emitter, listener: (e: T) => any, thisArgs?: any) { + if (nextTick) { + setTimeout(() => flush(listener, thisArgs)); + } else { + flush(listener, thisArgs); + } + } + }); + + return emitter.event; + } + + export interface IChainableEvent { + event: Event; + map(fn: (i: T) => O): IChainableEvent; + forEach(fn: (i: T) => void): IChainableEvent; + filter(fn: (e: T) => boolean): IChainableEvent; + reduce(merge: (last: R | undefined, event: T) => R, initial?: R): IChainableEvent; + latch(): IChainableEvent; + on(listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[]): IDisposable; + once(listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[]): IDisposable; + } + + class ChainableEvent implements IChainableEvent { + + get event(): Event { return this._event; } + + constructor(private _event: Event) { } + + map(fn: (i: T) => O): IChainableEvent { + return new ChainableEvent(map(this._event, fn)); + } + + forEach(fn: (i: T) => void): IChainableEvent { + return new ChainableEvent(forEach(this._event, fn)); + } + + filter(fn: (e: T) => boolean): IChainableEvent { + return new ChainableEvent(filter(this._event, fn)); + } + + reduce(merge: (last: R | undefined, event: T) => R, initial?: R): IChainableEvent { + return new ChainableEvent(reduce(this._event, merge, initial)); + } + + latch(): IChainableEvent { + return new ChainableEvent(latch(this._event)); + } + + on(listener: (e: T) => any, thisArgs: any, disposables: IDisposable[]) { + return this._event(listener, thisArgs, disposables); + } + + once(listener: (e: T) => any, thisArgs: any, disposables: IDisposable[]) { + return once(this._event)(listener, thisArgs, disposables); + } + } + + export function chain(event: Event): IChainableEvent { + return new ChainableEvent(event); + } + + export interface NodeEventEmitter { + on(event: string | symbol, listener: Function): this; + removeListener(event: string | symbol, listener: Function): this; + } + + export function fromNodeEventEmitter(emitter: NodeEventEmitter, eventName: string, map: (...args: any[]) => T = id => id): Event { + const fn = (...args: any[]) => result.fire(map(...args)); + const onFirstListenerAdd = () => emitter.on(eventName, fn); + const onLastListenerRemove = () => emitter.removeListener(eventName, fn); + const result = new Emitter({ onFirstListenerAdd, onLastListenerRemove }); + + return result.event; + } + + export function fromPromise(promise: Promise): Event { + const emitter = new Emitter(); + let shouldEmit = false; + + promise + .then(undefined, () => null) + .then(() => { + if (!shouldEmit) { + setTimeout(() => emitter.fire(undefined), 0); + } else { + emitter.fire(undefined); + } + }); + + shouldEmit = true; + return emitter.event; + } + + export function toPromise(event: Event): Promise { + return new Promise(c => once(event)(c)); + } +} + +type Listener = [(e: T) => void, any] | ((e: T) => void); + +export interface EmitterOptions { + onFirstListenerAdd?: Function; + onFirstListenerDidAdd?: Function; + onListenerDidAdd?: Function; + onLastListenerRemove?: Function; + leakWarningThreshold?: number; +} + +let _globalLeakWarningThreshold = -1; +export function setGlobalLeakWarningThreshold(n: number): IDisposable { + let oldValue = _globalLeakWarningThreshold; + _globalLeakWarningThreshold = n; + return { + dispose() { + _globalLeakWarningThreshold = oldValue; + } + }; +} + +class LeakageMonitor { + + private _stacks: Map | undefined; + private _warnCountdown: number = 0; + + constructor( + readonly customThreshold?: number, + readonly name: string = Math.random().toString(18).slice(2, 5), + ) { } + + dispose(): void { + if (this._stacks) { + this._stacks.clear(); + } + } + + check(listenerCount: number): undefined | (() => void) { + + let threshold = _globalLeakWarningThreshold; + if (typeof this.customThreshold === 'number') { + threshold = this.customThreshold; + } + + if (threshold <= 0 || listenerCount < threshold) { + return undefined; + } + + if (!this._stacks) { + this._stacks = new Map(); + } + let stack = new Error().stack!.split('\n').slice(3).join('\n'); + let count = (this._stacks.get(stack) || 0); + this._stacks.set(stack, count + 1); + this._warnCountdown -= 1; + + if (this._warnCountdown <= 0) { + // only warn on first exceed and then every time the limit + // is exceeded by 50% again + this._warnCountdown = threshold * 0.5; + + // find most frequent listener and print warning + let topStack: string; + let topCount: number = 0; + this._stacks.forEach((count, stack) => { + if (!topStack || topCount < count) { + topStack = stack; + topCount = count; + } + }); + + console.warn(`[${this.name}] potential listener LEAK detected, having ${listenerCount} listeners already. MOST frequent listener (${topCount}):`); + console.warn(topStack!); + } + + return () => { + let count = (this._stacks!.get(stack) || 0); + this._stacks!.set(stack, count - 1); + }; + } +} + +/** + * The Emitter can be used to expose an Event to the public + * to fire it from the insides. + * Sample: + class Document { + + private _onDidChange = new Emitter<(value:string)=>any>(); + + public onDidChange = this._onDidChange.event; + + // getter-style + // get onDidChange(): Event<(value:string)=>any> { + // return this._onDidChange.event; + // } + + private _doIt() { + //... + this._onDidChange.fire(value); + } + } + */ +export class Emitter { + + private static readonly _noop = function () { }; + + private readonly _options?: EmitterOptions; + private readonly _leakageMon?: LeakageMonitor; + private _disposed: boolean = false; + private _event?: Event; + private _deliveryQueue: [Listener, T][]; + protected _listeners?: LinkedList>; + + constructor(options?: EmitterOptions) { + this._options = options; + this._leakageMon = _globalLeakWarningThreshold > 0 + ? new LeakageMonitor(this._options && this._options.leakWarningThreshold) + : undefined; + } + + /** + * For the public to allow to subscribe + * to events from this Emitter + */ + get event(): Event { + if (!this._event) { + this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[]) => { + if (!this._listeners) { + this._listeners = new LinkedList(); + } + + const firstListener = this._listeners.isEmpty(); + + if (firstListener && this._options && this._options.onFirstListenerAdd) { + this._options.onFirstListenerAdd(this); + } + + const remove = this._listeners.push(!thisArgs ? listener : [listener, thisArgs]); + + if (firstListener && this._options && this._options.onFirstListenerDidAdd) { + this._options.onFirstListenerDidAdd(this); + } + + if (this._options && this._options.onListenerDidAdd) { + this._options.onListenerDidAdd(this, listener, thisArgs); + } + + // check and record this emitter for potential leakage + let removeMonitor: (() => void) | undefined; + if (this._leakageMon) { + removeMonitor = this._leakageMon.check(this._listeners.size); + } + + let result: IDisposable; + result = { + dispose: () => { + if (removeMonitor) { + removeMonitor(); + } + result.dispose = Emitter._noop; + if (!this._disposed) { + remove(); + if (this._options && this._options.onLastListenerRemove) { + const hasListeners = (this._listeners && !this._listeners.isEmpty()); + if (!hasListeners) { + this._options.onLastListenerRemove(this); + } + } + } + } + }; + if (Array.isArray(disposables)) { + disposables.push(result); + } + + return result; + }; + } + return this._event; + } + + /** + * To be kept private to fire an event to + * subscribers + */ + fire(event: T): void { + if (this._listeners) { + // put all [listener,event]-pairs into delivery queue + // then emit all event. an inner/nested event might be + // the driver of this + + if (!this._deliveryQueue) { + this._deliveryQueue = []; + } + + for (let iter = this._listeners.iterator(), e = iter.next(); !e.done; e = iter.next()) { + this._deliveryQueue.push([e.value, event]); + } + + while (this._deliveryQueue.length > 0) { + const [listener, event] = this._deliveryQueue.shift()!; + try { + if (typeof listener === 'function') { + listener.call(undefined, event); + } else { + listener[0].call(listener[1], event); + } + } catch (e) { + console.error(e); + } + } + } + } + + dispose() { + if (this._listeners) { + this._listeners = undefined; + } + if (this._deliveryQueue) { + this._deliveryQueue.length = 0; + } + if (this._leakageMon) { + this._leakageMon.dispose(); + } + this._disposed = true; + } +} + +export interface IWaitUntil { + waitUntil(thenable: Promise): void; +} + +export class AsyncEmitter extends Emitter { + + private _asyncDeliveryQueue: [Listener, T, Promise[]][]; + + async fireAsync(eventFn: (thenables: Promise[], listener: Function) => T): Promise { + if (!this._listeners) { + return; + } + + // put all [listener,event]-pairs into delivery queue + // then emit all event. an inner/nested event might be + // the driver of this + if (!this._asyncDeliveryQueue) { + this._asyncDeliveryQueue = []; + } + + for (let iter = this._listeners.iterator(), e = iter.next(); !e.done; e = iter.next()) { + let thenables: Promise[] = []; + this._asyncDeliveryQueue.push([e.value, eventFn(thenables, typeof e.value === 'function' ? e.value : e.value[0]), thenables]); + } + + while (this._asyncDeliveryQueue.length > 0) { + const [listener, event, thenables] = this._asyncDeliveryQueue.shift()!; + try { + if (typeof listener === 'function') { + listener.call(undefined, event); + } else { + listener[0].call(listener[1], event); + } + } catch (e) { + console.error(e); + continue; + } + + // freeze thenables-collection to enforce sync-calls to + // wait until and then wait for all thenables to resolve + Object.freeze(thenables); + await Promise.all(thenables); + } + } +} + +/** + * The EventBufferer is useful in situations in which you want + * to delay firing your events during some code. + * You can wrap that code and be sure that the event will not + * be fired during that wrap. + * + * ``` + * const emitter: Emitter; + * const delayer = new EventDelayer(); + * const delayedEvent = delayer.wrapEvent(emitter.event); + * + * delayedEvent(console.log); + * + * delayer.bufferEvents(() => { + * emitter.fire(); // event will not be fired yet + * }); + * + * // event will only be fired at this point + * ``` + */ +export class EventBufferer { + + private buffers: Function[][] = []; + + wrapEvent(event: Event): Event { + return (listener, thisArgs?, disposables?) => { + return event(i => { + const buffer = this.buffers[this.buffers.length - 1]; + + if (buffer) { + buffer.push(() => listener.call(thisArgs, i)); + } else { + listener.call(thisArgs, i); + } + }, undefined, disposables); + }; + } + + bufferEvents(fn: () => R): R { + const buffer: Array<() => R> = []; + this.buffers.push(buffer); + const r = fn(); + this.buffers.pop(); + buffer.forEach(flush => flush()); + return r; + } +} + +/** + * A Relay is an event forwarder which functions as a replugabble event pipe. + * Once created, you can connect an input event to it and it will simply forward + * events from that input event through its own `event` property. The `input` + * can be changed at any point in time. + */ +export class Relay implements IDisposable { + + private listening = false; + private inputEvent: Event = Event.None; + private inputEventListener: IDisposable = Disposable.None; + + private emitter = new Emitter({ + onFirstListenerDidAdd: () => { + this.listening = true; + this.inputEventListener = this.inputEvent(this.emitter.fire, this.emitter); + }, + onLastListenerRemove: () => { + this.listening = false; + this.inputEventListener.dispose(); + } + }); + + readonly event: Event = this.emitter.event; + + set input(event: Event) { + this.inputEvent = event; + + if (this.listening) { + this.inputEventListener.dispose(); + this.inputEventListener = event(this.emitter.fire, this.emitter); + } + } + + dispose() { + this.inputEventListener.dispose(); + this.emitter.dispose(); + } +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/common/iterator.ts b/src/main/custom-electron-titlebar/common/iterator.ts new file mode 100644 index 0000000..39c7f93 --- /dev/null +++ b/src/main/custom-electron-titlebar/common/iterator.ts @@ -0,0 +1,203 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IteratorDefinedResult { + readonly done: false; + readonly value: T; +} +export interface IteratorUndefinedResult { + readonly done: true; + readonly value: undefined; +} +export const FIN: IteratorUndefinedResult = { done: true, value: undefined }; +export type IteratorResult = IteratorDefinedResult | IteratorUndefinedResult; + +export interface Iterator { + next(): IteratorResult; +} + +export module Iterator { + const _empty: Iterator = { + next() { + return FIN; + } + }; + + export function empty(): Iterator { + return _empty; + } + + export function fromArray(array: T[], index = 0, length = array.length): Iterator { + return { + next(): IteratorResult { + if (index >= length) { + return FIN; + } + + return { done: false, value: array[index++] }; + } + }; + } + + export function from(elements: Iterator | T[] | undefined): Iterator { + if (!elements) { + return Iterator.empty(); + } else if (Array.isArray(elements)) { + return Iterator.fromArray(elements); + } else { + return elements; + } + } + + export function map(iterator: Iterator, fn: (t: T) => R): Iterator { + return { + next() { + const element = iterator.next(); + if (element.done) { + return FIN; + } else { + return { done: false, value: fn(element.value) }; + } + } + }; + } + + export function filter(iterator: Iterator, fn: (t: T) => boolean): Iterator { + return { + next() { + while (true) { + const element = iterator.next(); + if (element.done) { + return FIN; + } + if (fn(element.value)) { + return { done: false, value: element.value }; + } + } + } + }; + } + + export function forEach(iterator: Iterator, fn: (t: T) => void): void { + for (let next = iterator.next(); !next.done; next = iterator.next()) { + fn(next.value); + } + } + + export function collect(iterator: Iterator): T[] { + const result: T[] = []; + forEach(iterator, value => result.push(value)); + return result; + } +} + +export type ISequence = Iterator | T[]; + +export function getSequenceIterator(arg: Iterator | T[]): Iterator { + if (Array.isArray(arg)) { + return Iterator.fromArray(arg); + } else { + return arg; + } +} + +export interface INextIterator { + next(): T | null; +} + +export class ArrayIterator implements INextIterator { + + private items: T[]; + protected start: number; + protected end: number; + protected index: number; + + constructor(items: T[], start: number = 0, end: number = items.length, index = start - 1) { + this.items = items; + this.start = start; + this.end = end; + this.index = index; + } + + public first(): T | null { + this.index = this.start; + return this.current(); + } + + public next(): T | null { + this.index = Math.min(this.index + 1, this.end); + return this.current(); + } + + protected current(): T | null { + if (this.index === this.start - 1 || this.index === this.end) { + return null; + } + + return this.items[this.index]; + } +} + +export class ArrayNavigator extends ArrayIterator implements INavigator { + + constructor(items: T[], start: number = 0, end: number = items.length, index = start - 1) { + super(items, start, end, index); + } + + public current(): T | null { + return super.current(); + } + + public previous(): T | null { + this.index = Math.max(this.index - 1, this.start - 1); + return this.current(); + } + + public first(): T | null { + this.index = this.start; + return this.current(); + } + + public last(): T | null { + this.index = this.end - 1; + return this.current(); + } + + public parent(): T | null { + return null; + } +} + +export class MappedIterator implements INextIterator { + + constructor(protected iterator: INextIterator, protected fn: (item: T | null) => R) { + // noop + } + + next() { return this.fn(this.iterator.next()); } +} + +export interface INavigator extends INextIterator { + current(): T | null; + previous(): T | null; + parent(): T | null; + first(): T | null; + last(): T | null; + next(): T | null; +} + +export class MappedNavigator extends MappedIterator implements INavigator { + + constructor(protected navigator: INavigator, fn: (item: T) => R) { + super(navigator, fn); + } + + current() { return this.fn(this.navigator.current()); } + previous() { return this.fn(this.navigator.previous()); } + parent() { return this.fn(this.navigator.parent()); } + first() { return this.fn(this.navigator.first()); } + last() { return this.fn(this.navigator.last()); } + next() { return this.fn(this.navigator.next()); } +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/common/keyCodes.ts b/src/main/custom-electron-titlebar/common/keyCodes.ts new file mode 100644 index 0000000..0be8316 --- /dev/null +++ b/src/main/custom-electron-titlebar/common/keyCodes.ts @@ -0,0 +1,585 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { OperatingSystem } from './platform'; + +/** + * Virtual Key Codes, the value does not hold any inherent meaning. + * Inspired somewhat from https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx + * But these are "more general", as they should work across browsers & OS`s. + */ +export const enum KeyCode { + /** + * Placed first to cover the 0 value of the enum. + */ + Unknown = 0, + + Backspace = 1, + Tab = 2, + Enter = 3, + Shift = 4, + Ctrl = 5, + Alt = 6, + PauseBreak = 7, + CapsLock = 8, + Escape = 9, + Space = 10, + PageUp = 11, + PageDown = 12, + End = 13, + Home = 14, + LeftArrow = 15, + UpArrow = 16, + RightArrow = 17, + DownArrow = 18, + Insert = 19, + Delete = 20, + + KEY_0 = 21, + KEY_1 = 22, + KEY_2 = 23, + KEY_3 = 24, + KEY_4 = 25, + KEY_5 = 26, + KEY_6 = 27, + KEY_7 = 28, + KEY_8 = 29, + KEY_9 = 30, + + KEY_A = 31, + KEY_B = 32, + KEY_C = 33, + KEY_D = 34, + KEY_E = 35, + KEY_F = 36, + KEY_G = 37, + KEY_H = 38, + KEY_I = 39, + KEY_J = 40, + KEY_K = 41, + KEY_L = 42, + KEY_M = 43, + KEY_N = 44, + KEY_O = 45, + KEY_P = 46, + KEY_Q = 47, + KEY_R = 48, + KEY_S = 49, + KEY_T = 50, + KEY_U = 51, + KEY_V = 52, + KEY_W = 53, + KEY_X = 54, + KEY_Y = 55, + KEY_Z = 56, + + Meta = 57, + ContextMenu = 58, + + F1 = 59, + F2 = 60, + F3 = 61, + F4 = 62, + F5 = 63, + F6 = 64, + F7 = 65, + F8 = 66, + F9 = 67, + F10 = 68, + F11 = 69, + F12 = 70, + F13 = 71, + F14 = 72, + F15 = 73, + F16 = 74, + F17 = 75, + F18 = 76, + F19 = 77, + + NumLock = 78, + ScrollLock = 79, + + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the ';:' key + */ + US_SEMICOLON = 80, + /** + * For any country/region, the '+' key + * For the US standard keyboard, the '=+' key + */ + US_EQUAL = 81, + /** + * For any country/region, the ',' key + * For the US standard keyboard, the ',<' key + */ + US_COMMA = 82, + /** + * For any country/region, the '-' key + * For the US standard keyboard, the '-_' key + */ + US_MINUS = 83, + /** + * For any country/region, the '.' key + * For the US standard keyboard, the '.>' key + */ + US_DOT = 84, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the '/?' key + */ + US_SLASH = 85, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the '`~' key + */ + US_BACKTICK = 86, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the '[{' key + */ + US_OPEN_SQUARE_BRACKET = 87, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the '\|' key + */ + US_BACKSLASH = 88, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the ']}' key + */ + US_CLOSE_SQUARE_BRACKET = 89, + /** + * Used for miscellaneous characters; it can vary by keyboard. + * For the US standard keyboard, the ''"' key + */ + US_QUOTE = 90, + /** + * Used for miscellaneous characters; it can vary by keyboard. + */ + OEM_8 = 91, + /** + * Either the angle bracket key or the backslash key on the RT 102-key keyboard. + */ + OEM_102 = 92, + + NUMPAD_0 = 93, // VK_NUMPAD0, 0x60, Numeric keypad 0 key + NUMPAD_1 = 94, // VK_NUMPAD1, 0x61, Numeric keypad 1 key + NUMPAD_2 = 95, // VK_NUMPAD2, 0x62, Numeric keypad 2 key + NUMPAD_3 = 96, // VK_NUMPAD3, 0x63, Numeric keypad 3 key + NUMPAD_4 = 97, // VK_NUMPAD4, 0x64, Numeric keypad 4 key + NUMPAD_5 = 98, // VK_NUMPAD5, 0x65, Numeric keypad 5 key + NUMPAD_6 = 99, // VK_NUMPAD6, 0x66, Numeric keypad 6 key + NUMPAD_7 = 100, // VK_NUMPAD7, 0x67, Numeric keypad 7 key + NUMPAD_8 = 101, // VK_NUMPAD8, 0x68, Numeric keypad 8 key + NUMPAD_9 = 102, // VK_NUMPAD9, 0x69, Numeric keypad 9 key + + NUMPAD_MULTIPLY = 103, // VK_MULTIPLY, 0x6A, Multiply key + NUMPAD_ADD = 104, // VK_ADD, 0x6B, Add key + NUMPAD_SEPARATOR = 105, // VK_SEPARATOR, 0x6C, Separator key + NUMPAD_SUBTRACT = 106, // VK_SUBTRACT, 0x6D, Subtract key + NUMPAD_DECIMAL = 107, // VK_DECIMAL, 0x6E, Decimal key + NUMPAD_DIVIDE = 108, // VK_DIVIDE, 0x6F, + + /** + * Cover all key codes when IME is processing input. + */ + KEY_IN_COMPOSITION = 109, + + ABNT_C1 = 110, // Brazilian (ABNT) Keyboard + ABNT_C2 = 111, // Brazilian (ABNT) Keyboard + + /** + * Placed last to cover the length of the enum. + * Please do not depend on this value! + */ + MAX_VALUE +} + +class KeyCodeStrMap { + + private _keyCodeToStr: string[]; + private _strToKeyCode: { [str: string]: KeyCode; }; + + constructor() { + this._keyCodeToStr = []; + this._strToKeyCode = Object.create(null); + } + + define(keyCode: KeyCode, str: string): void { + this._keyCodeToStr[keyCode] = str; + this._strToKeyCode[str.toLowerCase()] = keyCode; + } + + keyCodeToStr(keyCode: KeyCode): string { + return this._keyCodeToStr[keyCode]; + } + + strToKeyCode(str: string): KeyCode { + return this._strToKeyCode[str.toLowerCase()] || KeyCode.Unknown; + } +} + +const uiMap = new KeyCodeStrMap(); +const userSettingsUSMap = new KeyCodeStrMap(); +const userSettingsGeneralMap = new KeyCodeStrMap(); + +(function () { + + function define(keyCode: KeyCode, uiLabel: string, usUserSettingsLabel: string = uiLabel, generalUserSettingsLabel: string = usUserSettingsLabel): void { + uiMap.define(keyCode, uiLabel); + userSettingsUSMap.define(keyCode, usUserSettingsLabel); + userSettingsGeneralMap.define(keyCode, generalUserSettingsLabel); + } + + define(KeyCode.Unknown, 'unknown'); + + define(KeyCode.Backspace, 'Backspace'); + define(KeyCode.Tab, 'Tab'); + define(KeyCode.Enter, 'Enter'); + define(KeyCode.Shift, 'Shift'); + define(KeyCode.Ctrl, 'Ctrl'); + define(KeyCode.Alt, 'Alt'); + define(KeyCode.PauseBreak, 'PauseBreak'); + define(KeyCode.CapsLock, 'CapsLock'); + define(KeyCode.Escape, 'Escape'); + define(KeyCode.Space, 'Space'); + define(KeyCode.PageUp, 'PageUp'); + define(KeyCode.PageDown, 'PageDown'); + define(KeyCode.End, 'End'); + define(KeyCode.Home, 'Home'); + + define(KeyCode.LeftArrow, 'LeftArrow', 'Left'); + define(KeyCode.UpArrow, 'UpArrow', 'Up'); + define(KeyCode.RightArrow, 'RightArrow', 'Right'); + define(KeyCode.DownArrow, 'DownArrow', 'Down'); + define(KeyCode.Insert, 'Insert'); + define(KeyCode.Delete, 'Delete'); + + define(KeyCode.KEY_0, '0'); + define(KeyCode.KEY_1, '1'); + define(KeyCode.KEY_2, '2'); + define(KeyCode.KEY_3, '3'); + define(KeyCode.KEY_4, '4'); + define(KeyCode.KEY_5, '5'); + define(KeyCode.KEY_6, '6'); + define(KeyCode.KEY_7, '7'); + define(KeyCode.KEY_8, '8'); + define(KeyCode.KEY_9, '9'); + + define(KeyCode.KEY_A, 'A'); + define(KeyCode.KEY_B, 'B'); + define(KeyCode.KEY_C, 'C'); + define(KeyCode.KEY_D, 'D'); + define(KeyCode.KEY_E, 'E'); + define(KeyCode.KEY_F, 'F'); + define(KeyCode.KEY_G, 'G'); + define(KeyCode.KEY_H, 'H'); + define(KeyCode.KEY_I, 'I'); + define(KeyCode.KEY_J, 'J'); + define(KeyCode.KEY_K, 'K'); + define(KeyCode.KEY_L, 'L'); + define(KeyCode.KEY_M, 'M'); + define(KeyCode.KEY_N, 'N'); + define(KeyCode.KEY_O, 'O'); + define(KeyCode.KEY_P, 'P'); + define(KeyCode.KEY_Q, 'Q'); + define(KeyCode.KEY_R, 'R'); + define(KeyCode.KEY_S, 'S'); + define(KeyCode.KEY_T, 'T'); + define(KeyCode.KEY_U, 'U'); + define(KeyCode.KEY_V, 'V'); + define(KeyCode.KEY_W, 'W'); + define(KeyCode.KEY_X, 'X'); + define(KeyCode.KEY_Y, 'Y'); + define(KeyCode.KEY_Z, 'Z'); + + define(KeyCode.Meta, 'Meta'); + define(KeyCode.ContextMenu, 'ContextMenu'); + + define(KeyCode.F1, 'F1'); + define(KeyCode.F2, 'F2'); + define(KeyCode.F3, 'F3'); + define(KeyCode.F4, 'F4'); + define(KeyCode.F5, 'F5'); + define(KeyCode.F6, 'F6'); + define(KeyCode.F7, 'F7'); + define(KeyCode.F8, 'F8'); + define(KeyCode.F9, 'F9'); + define(KeyCode.F10, 'F10'); + define(KeyCode.F11, 'F11'); + define(KeyCode.F12, 'F12'); + define(KeyCode.F13, 'F13'); + define(KeyCode.F14, 'F14'); + define(KeyCode.F15, 'F15'); + define(KeyCode.F16, 'F16'); + define(KeyCode.F17, 'F17'); + define(KeyCode.F18, 'F18'); + define(KeyCode.F19, 'F19'); + + define(KeyCode.NumLock, 'NumLock'); + define(KeyCode.ScrollLock, 'ScrollLock'); + + define(KeyCode.US_SEMICOLON, ';', ';', 'OEM_1'); + define(KeyCode.US_EQUAL, '=', '=', 'OEM_PLUS'); + define(KeyCode.US_COMMA, ',', ',', 'OEM_COMMA'); + define(KeyCode.US_MINUS, '-', '-', 'OEM_MINUS'); + define(KeyCode.US_DOT, '.', '.', 'OEM_PERIOD'); + define(KeyCode.US_SLASH, '/', '/', 'OEM_2'); + define(KeyCode.US_BACKTICK, '`', '`', 'OEM_3'); + define(KeyCode.ABNT_C1, 'ABNT_C1'); + define(KeyCode.ABNT_C2, 'ABNT_C2'); + define(KeyCode.US_OPEN_SQUARE_BRACKET, '[', '[', 'OEM_4'); + define(KeyCode.US_BACKSLASH, '\\', '\\', 'OEM_5'); + define(KeyCode.US_CLOSE_SQUARE_BRACKET, ']', ']', 'OEM_6'); + define(KeyCode.US_QUOTE, '\'', '\'', 'OEM_7'); + define(KeyCode.OEM_8, 'OEM_8'); + define(KeyCode.OEM_102, 'OEM_102'); + + define(KeyCode.NUMPAD_0, 'NumPad0'); + define(KeyCode.NUMPAD_1, 'NumPad1'); + define(KeyCode.NUMPAD_2, 'NumPad2'); + define(KeyCode.NUMPAD_3, 'NumPad3'); + define(KeyCode.NUMPAD_4, 'NumPad4'); + define(KeyCode.NUMPAD_5, 'NumPad5'); + define(KeyCode.NUMPAD_6, 'NumPad6'); + define(KeyCode.NUMPAD_7, 'NumPad7'); + define(KeyCode.NUMPAD_8, 'NumPad8'); + define(KeyCode.NUMPAD_9, 'NumPad9'); + + define(KeyCode.NUMPAD_MULTIPLY, 'NumPad_Multiply'); + define(KeyCode.NUMPAD_ADD, 'NumPad_Add'); + define(KeyCode.NUMPAD_SEPARATOR, 'NumPad_Separator'); + define(KeyCode.NUMPAD_SUBTRACT, 'NumPad_Subtract'); + define(KeyCode.NUMPAD_DECIMAL, 'NumPad_Decimal'); + define(KeyCode.NUMPAD_DIVIDE, 'NumPad_Divide'); + +})(); + +export namespace KeyCodeUtils { + export function toString(keyCode: KeyCode): string { + return uiMap.keyCodeToStr(keyCode); + } + export function fromString(key: string): KeyCode { + return uiMap.strToKeyCode(key); + } + + export function toUserSettingsUS(keyCode: KeyCode): string { + return userSettingsUSMap.keyCodeToStr(keyCode); + } + export function toUserSettingsGeneral(keyCode: KeyCode): string { + return userSettingsGeneralMap.keyCodeToStr(keyCode); + } + export function fromUserSettings(key: string): KeyCode { + return userSettingsUSMap.strToKeyCode(key) || userSettingsGeneralMap.strToKeyCode(key); + } +} + +/** + * Binary encoding strategy: + * ``` + * 1111 11 + * 5432 1098 7654 3210 + * ---- CSAW KKKK KKKK + * C = bit 11 = ctrlCmd flag + * S = bit 10 = shift flag + * A = bit 9 = alt flag + * W = bit 8 = winCtrl flag + * K = bits 0-7 = key code + * ``` + */ +const enum BinaryKeybindingsMask { + CtrlCmd = (1 << 11) >>> 0, + Shift = (1 << 10) >>> 0, + Alt = (1 << 9) >>> 0, + WinCtrl = (1 << 8) >>> 0, + KeyCode = 0x000000FF +} + +export const enum KeyMod { + CtrlCmd = (1 << 11) >>> 0, + Shift = (1 << 10) >>> 0, + Alt = (1 << 9) >>> 0, + WinCtrl = (1 << 8) >>> 0, +} + +export function KeyChord(firstPart: number, secondPart: number): number { + let chordPart = ((secondPart & 0x0000FFFF) << 16) >>> 0; + return (firstPart | chordPart) >>> 0; +} + +export function createKeybinding(keybinding: number, OS: OperatingSystem): Keybinding | null { + if (keybinding === 0) { + return null; + } + const firstPart = (keybinding & 0x0000FFFF) >>> 0; + const chordPart = (keybinding & 0xFFFF0000) >>> 16; + if (chordPart !== 0) { + return new ChordKeybinding( + createSimpleKeybinding(firstPart, OS), + createSimpleKeybinding(chordPart, OS), + ); + } + return createSimpleKeybinding(firstPart, OS); +} + +export function createSimpleKeybinding(keybinding: number, OS: OperatingSystem): SimpleKeybinding { + + const ctrlCmd = (keybinding & BinaryKeybindingsMask.CtrlCmd ? true : false); + const winCtrl = (keybinding & BinaryKeybindingsMask.WinCtrl ? true : false); + + const ctrlKey = (OS === OperatingSystem.Macintosh ? winCtrl : ctrlCmd); + const shiftKey = (keybinding & BinaryKeybindingsMask.Shift ? true : false); + const altKey = (keybinding & BinaryKeybindingsMask.Alt ? true : false); + const metaKey = (OS === OperatingSystem.Macintosh ? ctrlCmd : winCtrl); + const keyCode = (keybinding & BinaryKeybindingsMask.KeyCode); + + return new SimpleKeybinding(ctrlKey, shiftKey, altKey, metaKey, keyCode); +} + +export const enum KeybindingType { + Simple = 1, + Chord = 2 +} + +export class SimpleKeybinding { + public readonly type = KeybindingType.Simple; + + public readonly ctrlKey: boolean; + public readonly shiftKey: boolean; + public readonly altKey: boolean; + public readonly metaKey: boolean; + public readonly keyCode: KeyCode; + + constructor(ctrlKey: boolean, shiftKey: boolean, altKey: boolean, metaKey: boolean, keyCode: KeyCode) { + this.ctrlKey = ctrlKey; + this.shiftKey = shiftKey; + this.altKey = altKey; + this.metaKey = metaKey; + this.keyCode = keyCode; + } + + public equals(other: Keybinding): boolean { + if (other.type !== KeybindingType.Simple) { + return false; + } + return ( + this.ctrlKey === other.ctrlKey + && this.shiftKey === other.shiftKey + && this.altKey === other.altKey + && this.metaKey === other.metaKey + && this.keyCode === other.keyCode + ); + } + + public getHashCode(): string { + let ctrl = this.ctrlKey ? '1' : '0'; + let shift = this.shiftKey ? '1' : '0'; + let alt = this.altKey ? '1' : '0'; + let meta = this.metaKey ? '1' : '0'; + return `${ctrl}${shift}${alt}${meta}${this.keyCode}`; + } + + public isModifierKey(): boolean { + return ( + this.keyCode === KeyCode.Unknown + || this.keyCode === KeyCode.Ctrl + || this.keyCode === KeyCode.Meta + || this.keyCode === KeyCode.Alt + || this.keyCode === KeyCode.Shift + ); + } + + /** + * Does this keybinding refer to the key code of a modifier and it also has the modifier flag? + */ + public isDuplicateModifierCase(): boolean { + return ( + (this.ctrlKey && this.keyCode === KeyCode.Ctrl) + || (this.shiftKey && this.keyCode === KeyCode.Shift) + || (this.altKey && this.keyCode === KeyCode.Alt) + || (this.metaKey && this.keyCode === KeyCode.Meta) + ); + } +} + +export class ChordKeybinding { + public readonly type = KeybindingType.Chord; + + public readonly firstPart: SimpleKeybinding; + public readonly chordPart: SimpleKeybinding; + + constructor(firstPart: SimpleKeybinding, chordPart: SimpleKeybinding) { + this.firstPart = firstPart; + this.chordPart = chordPart; + } + + public getHashCode(): string { + return `${this.firstPart.getHashCode()};${this.chordPart.getHashCode()}`; + } +} + +export type Keybinding = SimpleKeybinding | ChordKeybinding; + +export class ResolvedKeybindingPart { + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + + readonly keyLabel: string | null; + readonly keyAriaLabel: string | null; + + constructor(ctrlKey: boolean, shiftKey: boolean, altKey: boolean, metaKey: boolean, kbLabel: string | null, kbAriaLabel: string | null) { + this.ctrlKey = ctrlKey; + this.shiftKey = shiftKey; + this.altKey = altKey; + this.metaKey = metaKey; + this.keyLabel = kbLabel; + this.keyAriaLabel = kbAriaLabel; + } +} + +/** + * A resolved keybinding. Can be a simple keybinding or a chord keybinding. + */ +export abstract class ResolvedKeybinding { + /** + * This prints the binding in a format suitable for displaying in the UI. + */ + public abstract getLabel(): string | null; + /** + * This prints the binding in a format suitable for ARIA. + */ + public abstract getAriaLabel(): string | null; + /** + * This prints the binding in a format suitable for electron's accelerators. + * See https://github.com/electron/electron/blob/master/docs/api/accelerator.md + */ + public abstract getElectronAccelerator(): string | null; + /** + * This prints the binding in a format suitable for user settings. + */ + public abstract getUserSettingsLabel(): string | null; + /** + * Is the user settings label reflecting the label? + */ + public abstract isWYSIWYG(): boolean; + + /** + * Is the binding a chord? + */ + public abstract isChord(): boolean; + + /** + * Returns the firstPart, chordPart that should be used for dispatching. + */ + public abstract getDispatchParts(): [string | null, string | null]; + /** + * Returns the firstPart, chordPart of the keybinding. + * For simple keybindings, the second element will be null. + */ + public abstract getParts(): [ResolvedKeybindingPart, ResolvedKeybindingPart | null]; +} diff --git a/src/main/custom-electron-titlebar/common/lifecycle.ts b/src/main/custom-electron-titlebar/common/lifecycle.ts new file mode 100644 index 0000000..e0397cb --- /dev/null +++ b/src/main/custom-electron-titlebar/common/lifecycle.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IDisposable { + dispose(): void; +} + +export function isDisposable(thing: E): thing is E & IDisposable { + return typeof (thing).dispose === 'function' + && (thing).dispose.length === 0; +} + +export function dispose(disposable: T): T; +export function dispose(...disposables: Array): T[]; +export function dispose(disposables: T[]): T[]; +export function dispose(first: T | T[], ...rest: T[]): T | T[] | undefined { + if (Array.isArray(first)) { + first.forEach(d => d && d.dispose()); + return []; + } else if (rest.length === 0) { + if (first) { + first.dispose(); + return first; + } + return undefined; + } else { + dispose(first); + dispose(rest); + return []; + } +} + +export function combinedDisposable(disposables: IDisposable[]): IDisposable { + return { dispose: () => dispose(disposables) }; +} + +export function toDisposable(fn: () => void): IDisposable { + return { dispose() { fn(); } }; +} + +export abstract class Disposable implements IDisposable { + + static None = Object.freeze({ dispose() { } }); + + protected _toDispose: IDisposable[] = []; + protected get toDispose(): IDisposable[] { return this._toDispose; } + + private _lifecycle_disposable_isDisposed = false; + + public dispose(): void { + this._lifecycle_disposable_isDisposed = true; + this._toDispose = dispose(this._toDispose); + } + + protected _register(t: T): T { + if (this._lifecycle_disposable_isDisposed) { + console.warn('Registering disposable on object that has already been disposed.'); + t.dispose(); + } else { + this._toDispose.push(t); + } + + return t; + } +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/common/linkedList.ts b/src/main/custom-electron-titlebar/common/linkedList.ts new file mode 100644 index 0000000..a103f5b --- /dev/null +++ b/src/main/custom-electron-titlebar/common/linkedList.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Iterator, IteratorResult, FIN } from './iterator'; + +class Node { + element: E; + next: Node | undefined; + prev: Node | undefined; + + constructor(element: E) { + this.element = element; + } +} + +export class LinkedList { + + private _first: Node | undefined; + private _last: Node | undefined; + private _size: number = 0; + + get size(): number { + return this._size; + } + + isEmpty(): boolean { + return !this._first; + } + + clear(): void { + this._first = undefined; + this._last = undefined; + this._size = 0; + } + + unshift(element: E): () => void { + return this._insert(element, false); + } + + push(element: E): () => void { + return this._insert(element, true); + } + + private _insert(element: E, atTheEnd: boolean): () => void { + const newNode = new Node(element); + if (!this._first) { + this._first = newNode; + this._last = newNode; + + } else if (atTheEnd) { + // push + const oldLast = this._last!; + this._last = newNode; + newNode.prev = oldLast; + oldLast.next = newNode; + + } else { + // unshift + const oldFirst = this._first; + this._first = newNode; + newNode.next = oldFirst; + oldFirst.prev = newNode; + } + this._size += 1; + return this._remove.bind(this, newNode); + } + + + shift(): E | undefined { + if (!this._first) { + return undefined; + } else { + const res = this._first.element; + this._remove(this._first); + return res; + } + } + + pop(): E | undefined { + if (!this._last) { + return undefined; + } else { + const res = this._last.element; + this._remove(this._last); + return res; + } + } + + private _remove(node: Node): void { + let candidate: Node | undefined = this._first; + while (candidate instanceof Node) { + if (candidate !== node) { + candidate = candidate.next; + continue; + } + if (candidate.prev && candidate.next) { + // middle + let anchor = candidate.prev; + anchor.next = candidate.next; + candidate.next.prev = anchor; + + } else if (!candidate.prev && !candidate.next) { + // only node + this._first = undefined; + this._last = undefined; + + } else if (!candidate.next) { + // last + this._last = this._last!.prev!; + this._last.next = undefined; + + } else if (!candidate.prev) { + // first + this._first = this._first!.next!; + this._first.prev = undefined; + } + + // done + this._size -= 1; + break; + } + } + + iterator(): Iterator { + let element: { done: false; value: E; }; + let node = this._first; + return { + next(): IteratorResult { + if (!node) { + return FIN; + } + + if (!element) { + element = { done: false, value: node.element }; + } else { + element.value = node.element; + } + node = node.next; + return element; + } + }; + } + + toArray(): E[] { + let result: E[] = []; + for (let node = this._first; node instanceof Node; node = node.next) { + result.push(node.element); + } + return result; + } +} \ No newline at end of file diff --git a/src/main/custom-electron-titlebar/common/platform.ts b/src/main/custom-electron-titlebar/common/platform.ts new file mode 100644 index 0000000..44c1d24 --- /dev/null +++ b/src/main/custom-electron-titlebar/common/platform.ts @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +let _isWindows = false; +let _isMacintosh = false; +let _isLinux = false; +let _isNative = false; +let _isWeb = false; +let _locale: string | undefined = undefined; +let _language: string | undefined = undefined; +let _translationsConfigFile: string | undefined = undefined; + +interface NLSConfig { + locale: string; + availableLanguages: { [key: string]: string; }; + _translationsConfigFile: string; +} + +export interface IProcessEnvironment { + [key: string]: string; +} + +interface INodeProcess { + platform: string; + env: IProcessEnvironment; + getuid(): number; + nextTick: Function; + versions?: { + electron?: string; + }; + type?: string; +} +declare let process: INodeProcess; +declare let global: any; + +interface INavigator { + userAgent: string; + language: string; +} +declare let navigator: INavigator; +declare let self: any; + +export const LANGUAGE_DEFAULT = 'en'; + +const isElectronRenderer = (typeof process !== 'undefined' && typeof process.versions !== 'undefined' && typeof process.versions.electron !== 'undefined' && process.type === 'renderer'); + +// OS detection +if (typeof navigator === 'object' && !isElectronRenderer) { + const userAgent = navigator.userAgent; + _isWindows = userAgent.indexOf('Windows') >= 0; + _isMacintosh = userAgent.indexOf('Macintosh') >= 0; + _isLinux = userAgent.indexOf('Linux') >= 0; + _isWeb = true; + _locale = navigator.language; + _language = _locale; +} else if (typeof process === 'object') { + _isWindows = (process.platform === 'win32'); + _isMacintosh = (process.platform === 'darwin'); + _isLinux = (process.platform === 'linux'); + _locale = LANGUAGE_DEFAULT; + _language = LANGUAGE_DEFAULT; + const rawNlsConfig = process.env['VSCODE_NLS_CONFIG']; + if (rawNlsConfig) { + try { + const nlsConfig: NLSConfig = JSON.parse(rawNlsConfig); + const resolved = nlsConfig.availableLanguages['*']; + _locale = nlsConfig.locale; + // VSCode's default language is 'en' + _language = resolved ? resolved : LANGUAGE_DEFAULT; + _translationsConfigFile = nlsConfig._translationsConfigFile; + } catch (e) { + } + } + _isNative = true; +} + +export const enum Platform { + Web, + Mac, + Linux, + Windows +} +export function PlatformToString(platform: Platform) { + switch (platform) { + case Platform.Web: return 'Web'; + case Platform.Mac: return 'Mac'; + case Platform.Linux: return 'Linux'; + case Platform.Windows: return 'Windows'; + } +} + +let _platform: Platform = Platform.Web; +if (_isNative) { + if (_isMacintosh) { + _platform = Platform.Mac; + } else if (_isWindows) { + _platform = Platform.Windows; + } else if (_isLinux) { + _platform = Platform.Linux; + } +} + +export const isWindows = _isWindows; +export const isMacintosh = _isMacintosh; +export const isLinux = _isLinux; +export const isNative = _isNative; +export const isWeb = _isWeb; +export const platform = _platform; + +export function isRootUser(): boolean { + return _isNative && !_isWindows && (process.getuid() === 0); +} + +/** + * The language used for the user interface. The format of + * the string is all lower case (e.g. zh-tw for Traditional + * Chinese) + */ +export const language = _language; + +/** + * The OS locale or the locale specified by --locale. The format of + * the string is all lower case (e.g. zh-tw for Traditional + * Chinese). The UI is not necessarily shown in the provided locale. + */ +export const locale = _locale; + +/** + * The translatios that are available through language packs. + */ +export const translationsConfigFile = _translationsConfigFile; + +const _globals = (typeof self === 'object' ? self : typeof global === 'object' ? global : {} as any); +export const globals: any = _globals; + +let _setImmediate: ((callback: (...args: any[]) => void) => number) | null = null; +export function setImmediate(callback: (...args: any[]) => void): number { + if (_setImmediate === null) { + if (globals.setImmediate) { + _setImmediate = globals.setImmediate.bind(globals); + } else if (typeof process !== 'undefined' && typeof process.nextTick === 'function') { + _setImmediate = process.nextTick.bind(process); + } else { + _setImmediate = globals.setTimeout.bind(globals); + } + } + return _setImmediate!(callback); +} + +export const enum OperatingSystem { + Windows = 1, + Macintosh = 2, + Linux = 3 +} +export const OS = (_isMacintosh ? OperatingSystem.Macintosh : (_isWindows ? OperatingSystem.Windows : OperatingSystem.Linux)); + +export const enum AccessibilitySupport { + /** + * This should be the browser case where it is not known if a screen reader is attached or no. + */ + Unknown = 0, + + Disabled = 1, + + Enabled = 2 +} diff --git a/src/main/custom-electron-titlebar/index.ts b/src/main/custom-electron-titlebar/index.ts new file mode 100644 index 0000000..0be7940 --- /dev/null +++ b/src/main/custom-electron-titlebar/index.ts @@ -0,0 +1,8 @@ +/*------------------------------------------------------------------------------------------- + * Copyright (c) 2018 Alex Torres + * Licensed under the MIT License. See License in the project root for license information. + *------------------------------------------------------------------------------------------*/ + +export * from './titlebar'; +export * from './themebar'; +export * from './common/color'; diff --git a/src/main/custom-electron-titlebar/menu/menu.ts b/src/main/custom-electron-titlebar/menu/menu.ts new file mode 100644 index 0000000..e4cc08b --- /dev/null +++ b/src/main/custom-electron-titlebar/menu/menu.ts @@ -0,0 +1,690 @@ +/*-------------------------------------------------------------------------------------------------------- + * This file has been modified by @AlexTorresSk (http://github.com/AlexTorresSk) + * to work in custom-electron-titlebar. + * + * The original copy of this file and its respective license are in https://github.com/Microsoft/vscode/ + * + * Copyright (c) 2018 Alex Torres + * Licensed under the MIT License. See License in the project root for license information. + *-------------------------------------------------------------------------------------------------------*/ + +import { Color } from "../common/color"; +import { addClass, addDisposableListener, EventType, isAncestor, hasClass, append, addClasses, $, removeNode, EventHelper, EventLike } from "../common/dom"; +import { KeyCode, KeyCodeUtils, KeyMod } from "../common/keyCodes"; +import { isLinux } from "../common/platform"; +import { StandardKeyboardEvent } from "../browser/keyboardEvent"; +import { IMenuItem, CETMenuItem } from "./menuitem"; +import { Disposable, dispose, IDisposable } from "../common/lifecycle"; +import { Event, Emitter } from "../common/event"; +import { RunOnceScheduler } from "../common/async"; +import { MenuItem, Menu } from "electron"; + +export const MENU_MNEMONIC_REGEX = /\(&([^\s&])\)|(^|[^&])&([^\s&])/; +export const MENU_ESCAPED_MNEMONIC_REGEX = /(&)?(&)([^\s&])/g; + +export interface IMenuOptions { + ariaLabel?: string; + enableMnemonics?: boolean; +} + +export interface IMenuStyle { + foregroundColor?: Color; + backgroundColor?: Color; + selectionForegroundColor?: Color; + selectionBackgroundColor?: Color; + separatorColor?: Color; +} + +interface ISubMenuData { + parent: CETMenu; + submenu?: CETMenu; +} + +interface ActionTrigger { + keys: KeyCode[]; + keyDown: boolean; +} + +export class CETMenu extends Disposable { + + items: IMenuItem[]; + + private focusedItem?: number; + private menuContainer: HTMLElement; + private mnemonics: Map>; + private options: IMenuOptions; + private closeSubMenu: () => void; + + private triggerKeys: ActionTrigger = { + keys: [KeyCode.Enter, KeyCode.Space], + keyDown: true + } + + parentData: ISubMenuData = { + parent: this + }; + + private _onDidCancel = this._register(new Emitter()); + get onDidCancel(): Event { return this._onDidCancel.event; } + + constructor(container: HTMLElement, options: IMenuOptions = {}, closeSubMenu = () => { }) { + super(); + + this.menuContainer = container; + this.options = options; + this.closeSubMenu = closeSubMenu; + this.items = []; + this.focusedItem = undefined; + this.mnemonics = new Map>(); + + this._register(addDisposableListener(this.menuContainer, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + let eventHandled = true; + + if (event.equals(KeyCode.UpArrow)) { + this.focusPrevious(); + } else if (event.equals(KeyCode.DownArrow)) { + this.focusNext(); + } else if (event.equals(KeyCode.Escape)) { + this.cancel(); + } else if (this.isTriggerKeyEvent(event)) { + // Staying out of the else branch even if not triggered + if (this.triggerKeys && this.triggerKeys.keyDown) { + this.doTrigger(event); + } + } else { + eventHandled = false; + } + + if (eventHandled) { + event.preventDefault(); + event.stopPropagation(); + } + })); + + this._register(addDisposableListener(this.menuContainer, EventType.KEY_UP, e => { + const event = new StandardKeyboardEvent(e); + + // Run action on Enter/Space + if (this.isTriggerKeyEvent(event)) { + if (this.triggerKeys && !this.triggerKeys.keyDown) { + this.doTrigger(event); + } + + event.preventDefault(); + event.stopPropagation(); + } + + // Recompute focused item + else if (event.equals(KeyCode.Tab) || event.equals(KeyMod.Shift | KeyCode.Tab)) { + this.updateFocusedItem(); + } + })); + + if (options.enableMnemonics) { + this._register(addDisposableListener(this.menuContainer, EventType.KEY_DOWN, (e) => { + const key = KeyCodeUtils.fromString(e.key); + if (this.mnemonics.has(key)) { + const items = this.mnemonics.get(key)!; + + if (items.length === 1) { + if (items[0] instanceof Submenu) { + this.focusItemByElement(items[0].getContainer()); + } + + items[0].onClick(e); + } + + if (items.length > 1) { + const item = items.shift(); + if (item) { + this.focusItemByElement(item.getContainer()); + items.push(item); + } + + this.mnemonics.set(key, items); + } + } + })); + } + + if (isLinux) { + this._register(addDisposableListener(this.menuContainer, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + + if (event.equals(KeyCode.Home) || event.equals(KeyCode.PageUp)) { + this.focusedItem = this.items.length - 1; + this.focusNext(); + EventHelper.stop(e, true); + } else if (event.equals(KeyCode.End) || event.equals(KeyCode.PageDown)) { + this.focusedItem = 0; + this.focusPrevious(); + EventHelper.stop(e, true); + } + })); + } + + this._register(addDisposableListener(this.menuContainer, EventType.MOUSE_OUT, e => { + let relatedTarget = e.relatedTarget as HTMLElement; + if (!isAncestor(relatedTarget, this.menuContainer)) { + this.focusedItem = undefined; + this.updateFocus(); + e.stopPropagation(); + } + })); + + this._register(addDisposableListener(this.menuContainer, EventType.MOUSE_UP, e => { + // Absorb clicks in menu dead space https://github.com/Microsoft/vscode/issues/63575 + EventHelper.stop(e, true); + })); + + this._register(addDisposableListener(this.menuContainer, EventType.MOUSE_OVER, e => { + let target = e.target as HTMLElement; + + if (!target || !isAncestor(target, this.menuContainer) || target === this.menuContainer) { + return; + } + + while (target.parentElement !== this.menuContainer && target.parentElement !== null) { + target = target.parentElement; + } + + if (hasClass(target, 'action-item')) { + const lastFocusedItem = this.focusedItem; + this.setFocusedItem(target); + + if (lastFocusedItem !== this.focusedItem) { + this.updateFocus(); + } + } + })); + + if (this.options.ariaLabel) { + this.menuContainer.setAttribute('aria-label', this.options.ariaLabel); + } + + //container.style.maxHeight = `${Math.max(10, window.innerHeight - container.getBoundingClientRect().top - 70)}px`; + } + + setAriaLabel(label: string): void { + if (label) { + this.menuContainer.setAttribute('aria-label', label); + } else { + this.menuContainer.removeAttribute('aria-label'); + } + } + + private isTriggerKeyEvent(event: StandardKeyboardEvent): boolean { + let ret = false; + if (this.triggerKeys) { + this.triggerKeys.keys.forEach(keyCode => { + ret = ret || event.equals(keyCode); + }); + } + + return ret; + } + + private updateFocusedItem(): void { + for (let i = 0; i < this.menuContainer.children.length; i++) { + const elem = this.menuContainer.children[i]; + if (isAncestor(document.activeElement, elem)) { + this.focusedItem = i; + break; + } + } + } + + getContainer(): HTMLElement { + return this.menuContainer; + } + + createMenu(items: MenuItem[]) { + items.forEach((menuItem: MenuItem) => { + const itemElement = document.createElement('li'); + itemElement.className = 'action-item'; + itemElement.setAttribute('role', 'presentation'); + + // Prevent native context menu on actions + this._register(addDisposableListener(itemElement, EventType.CONTEXT_MENU, (e: EventLike) => { + e.preventDefault(); + e.stopPropagation(); + })); + + let item: CETMenuItem | null = null; + + if (menuItem.type === 'separator') { + item = new Separator(menuItem, this.options); + } else if (menuItem.type === 'submenu' || menuItem.submenu) { + const submenuItems = (menuItem.submenu as Menu).items; + item = new Submenu(menuItem, submenuItems, this.parentData, this.options); + + if (this.options.enableMnemonics) { + const mnemonic = item.getMnemonic(); + if (mnemonic && item.isEnabled()) { + let actionItems: CETMenuItem[] = []; + if (this.mnemonics.has(mnemonic)) { + actionItems = this.mnemonics.get(mnemonic)!; + } + + actionItems.push(item); + + this.mnemonics.set(mnemonic, actionItems); + } + } + } else { + const menuItemOptions: IMenuOptions = { enableMnemonics: this.options.enableMnemonics }; + item = new CETMenuItem(menuItem, menuItemOptions, this.closeSubMenu); + + if (this.options.enableMnemonics) { + const mnemonic = item.getMnemonic(); + if (mnemonic && item.isEnabled()) { + let actionItems: CETMenuItem[] = []; + if (this.mnemonics.has(mnemonic)) { + actionItems = this.mnemonics.get(mnemonic)!; + } + + actionItems.push(item); + + this.mnemonics.set(mnemonic, actionItems); + } + } + } + + item.render(itemElement); + + this.menuContainer.appendChild(itemElement); + this.items.push(item); + }); + } + + focus(index?: number): void; + focus(selectFirst?: boolean): void; + focus(arg?: any): void { + let selectFirst: boolean = false; + let index: number | undefined = undefined; + if (arg === undefined) { + selectFirst = true; + } else if (typeof arg === 'number') { + index = arg; + } else if (typeof arg === 'boolean') { + selectFirst = arg; + } + + 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(); + } + } + + private focusNext(): void { + if (typeof this.focusedItem === 'undefined') { + this.focusedItem = this.items.length - 1; + } + + const startIndex = this.focusedItem; + let item: IMenuItem; + + do { + this.focusedItem = (this.focusedItem + 1) % this.items.length; + item = this.items[this.focusedItem]; + } while ((this.focusedItem !== startIndex && !item.isEnabled()) || item.isSeparator()); + + if ((this.focusedItem === startIndex && !item.isEnabled()) || item.isSeparator()) { + this.focusedItem = undefined; + } + + this.updateFocus(); + } + + private focusPrevious(): void { + if (typeof this.focusedItem === 'undefined') { + this.focusedItem = 0; + } + + const startIndex = this.focusedItem; + let item: IMenuItem; + + do { + this.focusedItem = this.focusedItem - 1; + + if (this.focusedItem < 0) { + this.focusedItem = this.items.length - 1; + } + + item = this.items[this.focusedItem]; + } while ((this.focusedItem !== startIndex && !item.isEnabled()) || item.isSeparator()); + + if ((this.focusedItem === startIndex && !item.isEnabled()) || item.isSeparator()) { + this.focusedItem = undefined; + } + + this.updateFocus(); + } + + private updateFocus() { + if (typeof this.focusedItem === 'undefined') { + this.menuContainer.focus(); + } + + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + + if (i === this.focusedItem) { + if (item.isEnabled()) { + item.focus(); + } else { + this.menuContainer.focus(); + } + } else { + item.blur(); + } + } + } + + private doTrigger(event: StandardKeyboardEvent): void { + if (typeof this.focusedItem === 'undefined') { + return; //nothing to focus + } + + // trigger action + const item = this.items[this.focusedItem]; + if (item instanceof CETMenuItem) { + item.onClick(event); + } + } + + private cancel(): void { + if (document.activeElement instanceof HTMLElement) { + (document.activeElement).blur(); // remove focus from focused action + } + + this._onDidCancel.fire(); + } + + dispose() { + dispose(this.items); + this.items = []; + + removeNode(this.getContainer()); + + super.dispose(); + } + + style(style: IMenuStyle) { + const container = this.getContainer(); + + container.style.backgroundColor = style.backgroundColor ? style.backgroundColor.toString() : null; + + if (this.items) { + this.items.forEach(item => { + if (item instanceof CETMenuItem || item instanceof Separator) { + item.style(style); + } + }); + } + } + + private focusItemByElement(element: HTMLElement) { + const lastFocusedItem = this.focusedItem; + this.setFocusedItem(element); + + if (lastFocusedItem !== this.focusedItem) { + this.updateFocus(); + } + } + + private setFocusedItem(element: HTMLElement) { + for (let i = 0; i < this.menuContainer.children.length; i++) { + let elem = this.menuContainer.children[i]; + if (element === elem) { + this.focusedItem = i; + break; + } + } + } + +} + +class Submenu extends CETMenuItem { + + private mysubmenu: CETMenu | null; + private submenuContainer: HTMLElement | undefined; + private submenuIndicator: HTMLElement; + private submenuDisposables: IDisposable[] = []; + private mouseOver: boolean; + private showScheduler: RunOnceScheduler; + private hideScheduler: RunOnceScheduler; + + constructor(item: MenuItem, private submenuItems: MenuItem[], private parentData: ISubMenuData, private submenuOptions?: IMenuOptions) { + super(item, submenuOptions); + + this.showScheduler = new RunOnceScheduler(() => { + if (this.mouseOver) { + this.cleanupExistingSubmenu(false); + this.createSubmenu(false); + } + }, 250); + + this.hideScheduler = new RunOnceScheduler(() => { + if (this.container && (!isAncestor(document.activeElement, this.container) && this.parentData.submenu === this.mysubmenu)) { + this.parentData.parent.focus(false); + this.cleanupExistingSubmenu(true); + } + }, 750); + } + + render(container: HTMLElement): void { + super.render(container); + + if (!this.itemElement) { + return; + } + + addClass(this.itemElement, 'submenu-item'); + this.itemElement.setAttribute('aria-haspopup', 'true'); + + this.submenuIndicator = append(this.itemElement, $('span.submenu-indicator')); + this.submenuIndicator.setAttribute('aria-hidden', 'true'); + + this._register(addDisposableListener(this.container, EventType.KEY_UP, e => { + let event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.RightArrow) || event.equals(KeyCode.Enter)) { + EventHelper.stop(e, true); + + this.createSubmenu(true); + } + })); + + this._register(addDisposableListener(this.container, EventType.KEY_DOWN, e => { + let event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.RightArrow) || event.equals(KeyCode.Enter)) { + EventHelper.stop(e, true); + } + })); + + this._register(addDisposableListener(this.container, EventType.MOUSE_OVER, e => { + if (!this.mouseOver) { + this.mouseOver = true; + + this.showScheduler.schedule(); + } + })); + + this._register(addDisposableListener(this.container, EventType.MOUSE_LEAVE, e => { + this.mouseOver = false; + })); + + this._register(addDisposableListener(this.container, EventType.FOCUS_OUT, e => { + if (this.container && !isAncestor(document.activeElement, this.container)) { + this.hideScheduler.schedule(); + } + })); + } + + onClick(e: EventLike): void { + // stop clicking from trying to run an action + EventHelper.stop(e, true); + + this.cleanupExistingSubmenu(false); + this.createSubmenu(false); + } + + private cleanupExistingSubmenu(force: boolean): void { + if (this.parentData.submenu && (force || (this.parentData.submenu !== this.mysubmenu))) { + this.parentData.submenu.dispose(); + this.parentData.submenu = undefined; + + if (this.submenuContainer) { + this.submenuContainer = undefined; + } + } + } + + private createSubmenu(selectFirstItem = true): void { + if (!this.itemElement) { + return; + } + + if (!this.parentData.submenu) { + this.submenuContainer = append(this.container, $('ul.submenu')); + addClasses(this.submenuContainer, 'menubar-menu-container'); + + this.parentData.submenu = new CETMenu(this.submenuContainer, this.submenuOptions); + this.parentData.submenu.createMenu(this.submenuItems); + + if (this.menuStyle) { + this.parentData.submenu.style(this.menuStyle); + } + + const boundingRect = this.container.getBoundingClientRect(); + const childBoundingRect = this.submenuContainer.getBoundingClientRect(); + const computedStyles = getComputedStyle(this.parentData.parent.getContainer()); + const paddingTop = parseFloat(computedStyles.paddingTop || '0') || 0; + + if (window.innerWidth <= boundingRect.right + childBoundingRect.width) { + this.submenuContainer.style.left = '10px'; + this.submenuContainer.style.top = `${this.container.offsetTop + boundingRect.height}px`; + } else { + this.submenuContainer.style.left = `${this.container.offsetWidth}px`; + this.submenuContainer.style.top = `${this.container.offsetTop - paddingTop}px`; + } + + this.submenuDisposables.push(addDisposableListener(this.submenuContainer, EventType.KEY_UP, e => { + let event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.LeftArrow)) { + EventHelper.stop(e, true); + + this.parentData.parent.focus(); + + if (this.parentData.submenu) { + this.parentData.submenu.dispose(); + this.parentData.submenu = undefined; + } + + this.submenuDisposables = dispose(this.submenuDisposables); + this.submenuContainer = undefined; + } + })); + + this.submenuDisposables.push(addDisposableListener(this.submenuContainer, EventType.KEY_DOWN, e => { + let event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.LeftArrow)) { + EventHelper.stop(e, true); + } + })); + + this.submenuDisposables.push(this.parentData.submenu.onDidCancel(() => { + this.parentData.parent.focus(); + + if (this.parentData.submenu) { + this.parentData.submenu.dispose(); + this.parentData.submenu = undefined; + } + + this.submenuDisposables = dispose(this.submenuDisposables); + this.submenuContainer = undefined; + })); + + this.parentData.submenu.focus(selectFirstItem); + + this.mysubmenu = this.parentData.submenu; + } else { + this.parentData.submenu.focus(false); + } + } + + applyStyle(): void { + super.applyStyle(); + + if (!this.menuStyle) { + return; + } + + const isSelected = this.container && hasClass(this.container, 'focused'); + const fgColor = isSelected && this.menuStyle.selectionForegroundColor ? this.menuStyle.selectionForegroundColor : this.menuStyle.foregroundColor; + + this.submenuIndicator.style.backgroundColor = fgColor ? `${fgColor}` : null; + + if (this.parentData.submenu) { + this.parentData.submenu.style(this.menuStyle); + } + } + + dispose(): void { + super.dispose(); + + this.hideScheduler.dispose(); + + if (this.mysubmenu) { + this.mysubmenu.dispose(); + this.mysubmenu = null; + } + + if (this.submenuContainer) { + this.submenuDisposables = dispose(this.submenuDisposables); + this.submenuContainer = undefined; + } + } +} + +class Separator extends CETMenuItem { + + private separatorElement: HTMLElement; + + constructor(item: MenuItem, options: IMenuOptions) { + super(item, options); + } + + render(container: HTMLElement) { + if (container) { + this.separatorElement = append(container, $('a.action-label')); + this.separatorElement.setAttribute('role', 'presentation'); + addClass(this.separatorElement, 'separator'); + } + } + + style(style: IMenuStyle) { + this.separatorElement.style.borderBottomColor = style.separatorColor ? `${style.separatorColor}` : null; + } +} + +export function cleanMnemonic(label: string): string { + const regex = MENU_MNEMONIC_REGEX; + + const matches = regex.exec(label); + if (!matches) { + return label; + } + + const mnemonicInText = !matches[1]; + + return label.replace(regex, mnemonicInText ? '$2$3' : '').trim(); +} diff --git a/src/main/custom-electron-titlebar/menu/menuitem.ts b/src/main/custom-electron-titlebar/menu/menuitem.ts new file mode 100644 index 0000000..e5d9799 --- /dev/null +++ b/src/main/custom-electron-titlebar/menu/menuitem.ts @@ -0,0 +1,375 @@ +/*-------------------------------------------------------------------------------------------------------- + * This file has been modified by @AlexTorresSk (http://github.com/AlexTorresSk) + * to work in custom-electron-titlebar. + * + * The original copy of this file and its respective license are in https://github.com/Microsoft/vscode/ + * + * Copyright (c) 2018 Alex Torres + * Licensed under the MIT License. See License in the project root for license information. + *-------------------------------------------------------------------------------------------------------*/ + +import { EventType, addDisposableListener, addClass, removeClass, removeNode, append, $, hasClass, EventHelper, EventLike } from "../common/dom"; +import { BrowserWindow, remote, Accelerator, NativeImage, MenuItem } from "electron"; +import { IMenuStyle, MENU_MNEMONIC_REGEX, cleanMnemonic, MENU_ESCAPED_MNEMONIC_REGEX, IMenuOptions } from "./menu"; +import { KeyCode, KeyCodeUtils } from "../common/keyCodes"; +import { Disposable } from "../common/lifecycle"; +import { isMacintosh } from "../common/platform"; + +export interface IMenuItem { + render(element: HTMLElement): void; + isEnabled(): boolean; + isSeparator(): boolean; + focus(): void; + blur(): void; + dispose(): void; +} + +export class CETMenuItem extends Disposable implements IMenuItem { + + protected options: IMenuOptions; + protected menuStyle: IMenuStyle; + protected container: HTMLElement; + protected itemElement: HTMLElement; + + private item: MenuItem; + private labelElement: HTMLElement; + private checkElement: HTMLElement; + private iconElement: HTMLElement; + private mnemonic: KeyCode; + private closeSubMenu: () => void; + + private event: Electron.Event; + private currentWindow: BrowserWindow; + + constructor(item: MenuItem, options: IMenuOptions = {}, closeSubMenu = () => { }) { + super(); + + this.item = item; + this.options = options; + this.currentWindow = remote.getCurrentWindow(); + this.closeSubMenu = closeSubMenu; + + // Set mnemonic + if (this.item.label && options.enableMnemonics) { + let label = this.item.label; + if (label) { + let matches = MENU_MNEMONIC_REGEX.exec(label); + if (matches) { + this.mnemonic = KeyCodeUtils.fromString((!!matches[1] ? matches[1] : matches[2]).toLocaleUpperCase()); + } + } + } + } + + getContainer() { + return this.container; + } + + getItem(): MenuItem { + return this.item; + } + + isEnabled(): boolean { + return this.item.enabled; + } + + isSeparator(): boolean { + return this.item.type === 'separator'; + } + + render(container: HTMLElement): void { + this.container = container; + + this._register(addDisposableListener(this.container, EventType.MOUSE_DOWN, e => { + if (this.item.enabled && e.button === 0 && this.container) { + addClass(this.container, 'active'); + } + })); + + this._register(addDisposableListener(this.container, EventType.CLICK, e => { + if (this.item.enabled) { + this.onClick(e); + } + })); + + this._register(addDisposableListener(this.container, EventType.DBLCLICK, e => { + EventHelper.stop(e, true); + })); + + [EventType.MOUSE_UP, EventType.MOUSE_OUT].forEach(event => { + this._register(addDisposableListener(this.container!, event, e => { + EventHelper.stop(e); + removeClass(this.container!, 'active'); + })); + }); + + this.itemElement = append(this.container, $('a.action-menu-item')); + this.itemElement.setAttribute('role', 'menuitem'); + + if (this.mnemonic) { + this.itemElement.setAttribute('aria-keyshortcuts', `${this.mnemonic}`); + } + + this.checkElement = append(this.itemElement, $('span.menu-item-check')); + this.checkElement.setAttribute('role', 'none'); + + this.iconElement = append(this.itemElement, $('span.menu-item-icon')); + this.iconElement.setAttribute('role', 'none'); + + this.labelElement = append(this.itemElement, $('span.action-label')); + + this.setAccelerator(); + this.updateLabel(); + this.updateIcon(); + this.updateTooltip(); + this.updateEnabled(); + this.updateChecked(); + this.updateVisibility(); + } + + onClick(event: EventLike) { + EventHelper.stop(event, true); + + if (this.item.click) { + this.item.click(this.item as MenuItem, this.currentWindow, this.event); + } + + if (this.item.type === 'checkbox') { + this.item.checked = !this.item.checked; + this.updateChecked(); + } + this.closeSubMenu(); + } + + focus(): void { + if (this.container) { + this.container.focus(); + addClass(this.container, 'focused'); + } + + this.applyStyle(); + } + + blur(): void { + if (this.container) { + this.container.blur(); + removeClass(this.container, 'focused'); + } + + this.applyStyle(); + } + + setAccelerator(): void { + var accelerator = null; + + if (this.item.role) { + switch (this.item.role.toLocaleLowerCase()) { + case 'undo': + accelerator = 'CtrlOrCmd+Z'; + break; + case 'redo': + accelerator = 'CtrlOrCmd+Y'; + break; + case 'cut': + accelerator = 'CtrlOrCmd+X'; + break; + case 'copy': + accelerator = 'CtrlOrCmd+C'; + break; + case 'paste': + accelerator = 'CtrlOrCmd+V'; + break; + case 'selectall': + accelerator = 'CtrlOrCmd+A'; + break; + case 'minimize': + accelerator = 'CtrlOrCmd+M'; + break; + case 'close': + accelerator = 'CtrlOrCmd+W'; + break; + case 'reload': + accelerator = 'CtrlOrCmd+R'; + break; + case 'forcereload': + accelerator = 'CtrlOrCmd+Shift+R'; + break; + case 'toggledevtools': + accelerator = 'CtrlOrCmd+Shift+I'; + break; + case 'togglefullscreen': + accelerator = 'F11'; + break; + case 'resetzoom': + accelerator = 'CtrlOrCmd+0'; + break; + case 'zoomin': + accelerator = 'CtrlOrCmd+Shift+='; + break; + case 'zoomout': + accelerator = 'CtrlOrCmd+-'; + break; + } + } + + if (this.item.label && this.item.accelerator) { + accelerator = this.item.accelerator; + } + + if (accelerator !== null) { + append(this.itemElement, $('span.keybinding')).textContent = parseAccelerator(accelerator); + } + } + + updateLabel(): void { + if (this.item.label) { + let label = this.item.label; + + if (label) { + const cleanLabel = cleanMnemonic(label); + + if (!this.options.enableMnemonics) { + label = cleanLabel; + } + + if (this.labelElement) { + this.labelElement.setAttribute('aria-label', cleanLabel.replace(/&&/g, '&')); + } + + const matches = MENU_MNEMONIC_REGEX.exec(label); + + if (matches) { + label = escape(label); + + // This is global, reset it + MENU_ESCAPED_MNEMONIC_REGEX.lastIndex = 0; + let escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(label); + + // We can't use negative lookbehind so if we match our negative and skip + while (escMatch && escMatch[1]) { + escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(label); + } + + if (escMatch) { + label = `${label.substr(0, escMatch.index)}${label.substr(escMatch.index + escMatch[0].length)}`; + } + + label = label.replace(/&&/g, '&'); + if (this.itemElement) { + this.itemElement.setAttribute('aria-keyshortcuts', (!!matches[1] ? matches[1] : matches[3]).toLocaleLowerCase()); + } + } else { + label = label.replace(/&&/g, '&'); + } + } + + if (this.labelElement) { + this.labelElement.innerHTML = label.trim(); + } + } + } + + updateIcon(): void { + let icon: string | NativeImage | null = null; + + if (this.item.icon) { + icon = this.item.icon; + } + + if (icon) { + const iconE = append(this.iconElement, $('img')); + iconE.setAttribute('src', icon.toString()); + } + } + + updateTooltip(): void { + let title: string | null = null; + + if (this.item.sublabel) { + title = this.item.sublabel; + } else if (!this.item.label && this.item.label && this.item.icon) { + title = this.item.label; + + if (this.item.accelerator) { + title = parseAccelerator(this.item.accelerator); + } + } + + if (title) { + this.itemElement.title = title; + } + } + + updateEnabled() { + if (this.item.enabled && this.item.type !== 'separator') { + removeClass(this.container, 'disabled'); + this.container.tabIndex = 0; + } else { + addClass(this.container, 'disabled'); + } + } + + updateVisibility() { + if (this.item.visible === false && this.itemElement) { + this.itemElement.remove(); + } + } + + updateChecked() { + if (this.item.checked) { + addClass(this.itemElement, 'checked'); + this.itemElement.setAttribute('role', 'menuitemcheckbox'); + this.itemElement.setAttribute('aria-checked', 'true'); + } else { + removeClass(this.itemElement, 'checked'); + this.itemElement.setAttribute('role', 'menuitem'); + this.itemElement.setAttribute('aria-checked', 'false'); + } + } + + dispose(): void { + if (this.itemElement) { + removeNode(this.itemElement); + this.itemElement = undefined; + } + + super.dispose(); + } + + getMnemonic(): KeyCode { + return this.mnemonic; + } + + protected applyStyle() { + if (!this.menuStyle) { + return; + } + + const isSelected = this.container && hasClass(this.container, 'focused'); + const fgColor = isSelected && this.menuStyle.selectionForegroundColor ? this.menuStyle.selectionForegroundColor : this.menuStyle.foregroundColor; + const bgColor = isSelected && this.menuStyle.selectionBackgroundColor ? this.menuStyle.selectionBackgroundColor : this.menuStyle.backgroundColor; + + this.checkElement.style.backgroundColor = fgColor ? fgColor.toString() : null; + this.itemElement.style.color = fgColor ? fgColor.toString() : null; + this.itemElement.style.backgroundColor = bgColor ? bgColor.toString() : null; + } + + style(style: IMenuStyle): void { + this.menuStyle = style; + this.applyStyle(); + } +} + +function parseAccelerator(a: Accelerator): string { + var accelerator = a.toString(); + + if (!isMacintosh) { + accelerator = accelerator.replace(/(Cmd)|(Command)/gi, ''); + } else { + accelerator = accelerator.replace(/(Ctrl)|(Control)/gi, ''); + } + + accelerator = accelerator.replace(/(Or)/gi, ''); + + return accelerator; +} diff --git a/src/main/custom-electron-titlebar/menubar.ts b/src/main/custom-electron-titlebar/menubar.ts new file mode 100644 index 0000000..4db97ce --- /dev/null +++ b/src/main/custom-electron-titlebar/menubar.ts @@ -0,0 +1,781 @@ +/*-------------------------------------------------------------------------------------------------------- + * This file has been modified by @AlexTorresSk (http://github.com/AlexTorresSk) + * to work in custom-electron-titlebar. + * + * The original copy of this file and its respective license are in https://github.com/Microsoft/vscode/ + * + * Copyright (c) 2018 Alex Torres + * Licensed under the MIT License. See License in the project root for license information. + *-------------------------------------------------------------------------------------------------------*/ + +import { Color } from './common/color'; +import { remote, MenuItem, Menu } from 'electron'; +import { $, addDisposableListener, EventType, removeClass, addClass, append, removeNode, isAncestor, EventLike, EventHelper } from './common/dom'; +import { CETMenu, cleanMnemonic, MENU_MNEMONIC_REGEX, MENU_ESCAPED_MNEMONIC_REGEX, IMenuOptions, IMenuStyle } from './menu/menu'; +import { StandardKeyboardEvent } from './browser/keyboardEvent'; +import { KeyCodeUtils, KeyCode } from './common/keyCodes'; +import { Disposable, IDisposable, dispose } from './common/lifecycle'; +import { Event, Emitter } from './common/event'; +import { domEvent } from './browser/event'; +import { isMacintosh } from './common/platform'; + +export interface MenubarOptions { + /** + * The menu to show in the title bar. + * You can use `Menu` or not add this option and the menu created in the main process will be taken. + * The default menu is taken from the [`Menu.getApplicationMenu()`](https://electronjs.org/docs/api/menu#menugetapplicationmenu) + */ + menu?: Menu | null; + /** + * The position of menubar on titlebar. + * *The default is left* + */ + menuPosition?: "left" | "bottom"; + /** + * Enable the mnemonics on menubar and menu items + * *The default is true* + */ + enableMnemonics?: boolean; + /** + * The background color when the mouse is over the item. + */ + itemBackgroundColor?: Color; +} + +interface CustomItem { + menuItem: MenuItem; + buttonElement: HTMLElement; + titleElement: HTMLElement; + submenu: Menu; +} + +enum MenubarState { + HIDDEN, + VISIBLE, + FOCUSED, + OPEN +} + +export class Menubar extends Disposable { + + private menuItems: CustomItem[]; + + private focusedMenu: { + index: number; + holder?: HTMLElement; + widget?: CETMenu; + } | undefined; + + private focusToReturn: HTMLElement | undefined; + + // Input-related + private _mnemonicsInUse: boolean; + private openedViaKeyboard: boolean; + private awaitingAltRelease: boolean; + private ignoreNextMouseUp: boolean; + private mnemonics: Map; + + private _focusState: MenubarState; + + private _onVisibilityChange: Emitter; + private _onFocusStateChange: Emitter; + + private menuStyle: IMenuStyle; + private closeSubMenu: () => void; + + constructor(private container: HTMLElement, private options?: MenubarOptions, closeSubMenu = () => { }) { + super(); + + this.menuItems = []; + this.mnemonics = new Map(); + this.closeSubMenu = closeSubMenu; + this._focusState = MenubarState.VISIBLE; + + this._onVisibilityChange = this._register(new Emitter()); + this._onFocusStateChange = this._register(new Emitter()); + + this._register(ModifierKeyEmitter.getInstance().event(this.onModifierKeyToggled, this)); + + this._register(addDisposableListener(this.container, 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(addDisposableListener(window, EventType.MOUSE_DOWN, () => { + // This mouse event is outside the menubar so it counts as a focus out + if (this.isFocused) { + this.setUnfocusedState(); + } + })); + + this._register(addDisposableListener(this.container, 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(addDisposableListener(this.container, EventType.FOCUS_OUT, (e) => { + let event = e as FocusEvent; + + if (event.relatedTarget) { + if (!this.container.contains(event.relatedTarget as HTMLElement)) { + this.focusToReturn = undefined; + this.setUnfocusedState(); + } + } + })); + + this._register(addDisposableListener(window, 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(); + this.registerListeners(); + } + + private registerListeners(): void { + if (!isMacintosh) { + this._register(addDisposableListener(window, EventType.RESIZE, () => { + this.blur(); + })); + } + } + + setupMenubar(): void { + const topLevelMenus = this.options.menu.items; + + this._register(this.onFocusStateChange(e => this._onFocusStateChange.fire(e))); + this._register(this.onVisibilityChange(e => this._onVisibilityChange.fire(e))); + + topLevelMenus.forEach((menubarMenu) => { + const menuIndex = this.menuItems.length; + const cleanMenuLabel = cleanMnemonic(menubarMenu.label); + + const buttonElement = $('div.menubar-menu-button', { 'role': 'menuitem', 'tabindex': -1, 'aria-label': cleanMenuLabel, 'aria-haspopup': true }); + if (!menubarMenu.enabled) { + addClass(buttonElement, 'disabled'); + } + const titleElement = $('div.menubar-menu-title', { 'role': 'none', 'aria-hidden': true }); + + buttonElement.appendChild(titleElement); + append(this.container, buttonElement); + + let mnemonicMatches = MENU_MNEMONIC_REGEX.exec(menubarMenu.label); + + // Register mnemonics + if (mnemonicMatches) { + let mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[2]; + + this.registerMnemonic(this.menuItems.length, mnemonic); + } + + this.updateLabels(titleElement, buttonElement, menubarMenu.label); + + if (menubarMenu.enabled) { + this._register(addDisposableListener(buttonElement, 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(); + } + })); + + this._register(addDisposableListener(buttonElement, 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(addDisposableListener(buttonElement, EventType.MOUSE_UP, () => { + if (!this.ignoreNextMouseUp) { + if (this.isFocused) { + this.onMenuTriggered(menuIndex, true); + } + } else { + this.ignoreNextMouseUp = false; + } + })); + + this._register(addDisposableListener(buttonElement, EventType.MOUSE_ENTER, () => { + if (this.isOpen && !this.isCurrentMenu(menuIndex)) { + this.menuItems[menuIndex].buttonElement.focus(); + this.cleanupMenu(); + if (this.menuItems[menuIndex].submenu) { + this.showMenu(menuIndex, false); + } + } else if (this.isFocused && !this.isOpen) { + this.focusedMenu = { index: menuIndex }; + buttonElement.focus(); + } + })); + + this.menuItems.push({ + menuItem: menubarMenu, + submenu: menubarMenu.submenu, + buttonElement: buttonElement, + titleElement: titleElement + }); + } + }); + } + + private onClick(menuIndex: number) { + let electronEvent: Electron.Event; + const item = this.menuItems[menuIndex].menuItem; + + if (item.click) { + item.click(item as MenuItem, remote.getCurrentWindow(), electronEvent); + } + } + + public get onVisibilityChange(): Event { + return this._onVisibilityChange.event; + } + + public get onFocusStateChange(): Event { + return this._onFocusStateChange.event; + } + + dispose(): void { + super.dispose(); + + this.menuItems.forEach(menuBarMenu => { + removeNode(menuBarMenu.titleElement); + removeNode(menuBarMenu.buttonElement); + }); + } + + blur(): void { + this.setUnfocusedState(); + } + + setStyles(style: IMenuStyle) { + this.menuStyle = style; + } + + private updateLabels(titleElement: HTMLElement, buttonElement: HTMLElement, label: string): void { + const cleanMenuLabel = cleanMnemonic(label); + + // Update the button label to reflect mnemonics + + if (this.options.enableMnemonics) { + let innerHtml = escape(label); + + // This is global so reset it + MENU_ESCAPED_MNEMONIC_REGEX.lastIndex = 0; + let escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(innerHtml); + + // We can't use negative lookbehind so we match our negative and skip + while (escMatch && escMatch[1]) { + escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(innerHtml); + } + + if (escMatch) { + innerHtml = `${innerHtml.substr(0, escMatch.index)}${innerHtml.substr(escMatch.index + escMatch[0].length)}`; + } + + innerHtml = innerHtml.replace(/&&/g, '&'); + titleElement.innerHTML = innerHtml; + } else { + titleElement.innerHTML = cleanMenuLabel.replace(/&&/g, '&'); + } + + let mnemonicMatches = MENU_MNEMONIC_REGEX.exec(label); + + // Register mnemonics + if (mnemonicMatches) { + let mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[3]; + + if (this.options.enableMnemonics) { + buttonElement.setAttribute('aria-keyshortcuts', 'Alt+' + mnemonic.toLocaleLowerCase()); + } else { + buttonElement.removeAttribute('aria-keyshortcuts'); + } + } + } + + 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'; + } + } + + private showMenubar(): void { + if (this.container.style.display !== 'flex') { + this.container.style.display = 'flex'; + } + } + + private get focusState(): MenubarState { + return this._focusState; + } + + private set focusState(value: MenubarState) { + 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.cleanupMenu(); + } + + if (isFocused) { + this.focusedMenu = undefined; + + if (this.focusToReturn) { + this.focusToReturn.focus(); + this.focusToReturn = undefined; + } + } + + break; + + case MenubarState.VISIBLE: + if (!isVisible) { + this.showMenubar(); + } + + if (isOpen) { + this.cleanupMenu(); + } + + if (isFocused) { + if (this.focusedMenu) { + this.menuItems[this.focusedMenu.index].buttonElement.blur(); + } + + this.focusedMenu = undefined; + + if (this.focusToReturn) { + this.focusToReturn.focus(); + this.focusToReturn = undefined; + } + } + + break; + + case MenubarState.FOCUSED: + if (!isVisible) { + this.showMenubar(); + } + + if (isOpen) { + this.cleanupMenu(); + } + + if (this.focusedMenu) { + this.menuItems[this.focusedMenu.index].buttonElement.focus(); + } + + break; + + case MenubarState.OPEN: + if (!isVisible) { + this.showMenubar(); + } + + if (this.focusedMenu) { + if (this.menuItems[this.focusedMenu.index].submenu) { + this.showMenu(this.focusedMenu.index, this.openedViaKeyboard); + } + } + + break; + } + + this._focusState = 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 setUnfocusedState(): void { + 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.menuItems.length) % this.menuItems.length; + + if (newFocusedIndex === this.focusedMenu.index) { + return; + } + + if (this.isOpen) { + this.cleanupMenu(); + if (this.menuItems[newFocusedIndex].submenu) { + this.showMenu(newFocusedIndex); + } + } else if (this.isFocused) { + this.focusedMenu.index = newFocusedIndex; + this.menuItems[newFocusedIndex].buttonElement.focus(); + } + } + + private focusNext(): void { + if (!this.focusedMenu) { + return; + } + + let newFocusedIndex = (this.focusedMenu.index + 1) % this.menuItems.length; + + if (newFocusedIndex === this.focusedMenu.index) { + return; + } + + if (this.isOpen) { + this.cleanupMenu(); + if (this.menuItems[newFocusedIndex].submenu) { + this.showMenu(newFocusedIndex); + } + } else if (this.isFocused) { + this.focusedMenu.index = newFocusedIndex; + this.menuItems[newFocusedIndex].buttonElement.focus(); + } + } + + private updateMnemonicVisibility(visible: boolean): void { + if (this.menuItems) { + this.menuItems.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; + } + + private onMenuTriggered(menuIndex: number, clicked: boolean) { + if (this.isOpen) { + if (this.isCurrentMenu(menuIndex)) { + this.setUnfocusedState(); + } else { + this.cleanupMenu(); + if (this.menuItems[menuIndex].submenu) { + this.showMenu(menuIndex, this.openedViaKeyboard); + } else { + if (this.menuItems[menuIndex].menuItem.enabled) { + this.onClick(menuIndex); + } + } + } + } else { + this.focusedMenu = { index: menuIndex }; + this.openedViaKeyboard = !clicked; + + if (this.menuItems[menuIndex].submenu) { + this.focusState = MenubarState.OPEN; + } else { + if (this.menuItems[menuIndex].menuItem.enabled) { + this.onClick(menuIndex); + } + } + } + } + + private onModifierKeyToggled(modifierKeyStatus: IModifierKeyStatus): void { + const allModifiersReleased = !modifierKeyStatus.altKey && !modifierKeyStatus.ctrlKey && !modifierKeyStatus.shiftKey; + + // 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.options.enableMnemonics && this.menuItems && !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 cleanupMenu(): void { + if (this.focusedMenu) { + // Remove focus from the menus first + this.menuItems[this.focusedMenu.index].buttonElement.focus(); + + if (this.focusedMenu.holder) { + if (this.focusedMenu.holder.parentElement) { + 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 showMenu(menuIndex: number, selectFirst = true): void { + const customMenu = this.menuItems[menuIndex]; + const menuHolder = $('ul.menubar-menu-container'); + + addClass(customMenu.buttonElement, 'open'); + menuHolder.setAttribute('role', 'menu'); + menuHolder.tabIndex = 0; + menuHolder.style.top = `${customMenu.buttonElement.getBoundingClientRect().bottom}px`; + menuHolder.style.left = `${customMenu.buttonElement.getBoundingClientRect().left}px`; + + customMenu.buttonElement.appendChild(menuHolder); + + let menuOptions: IMenuOptions = { + enableMnemonics: this.mnemonicsInUse && this.options.enableMnemonics, + ariaLabel: customMenu.buttonElement.attributes['aria-label'].value + }; + + let menuWidget = new CETMenu(menuHolder, menuOptions, this.closeSubMenu); + menuWidget.createMenu(customMenu.submenu.items); + menuWidget.style(this.menuStyle); + + this._register(menuWidget.onDidCancel(() => { + this.focusState = MenubarState.FOCUSED; + })); + + menuWidget.focus(selectFirst); + + this.focusedMenu = { + index: menuIndex, + 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', true)(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', true)(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', true)(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); + } +} + +export function escape(html: string): string { + return html.replace(/[<>&]/g, function (match) { + switch (match) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + default: return match; + } + }); +} diff --git a/src/main/custom-electron-titlebar/themebar.ts b/src/main/custom-electron-titlebar/themebar.ts new file mode 100644 index 0000000..a4e5fde --- /dev/null +++ b/src/main/custom-electron-titlebar/themebar.ts @@ -0,0 +1,567 @@ +/*-------------------------------------------------------------------------------------------------------- + * Copyright (c) 2018 Alex Torres + * Licensed under the MIT License. See License in the project root for license information. + * + * This file has parts of one or more project files (VS Code) from Microsoft + * You can check your respective license and the original file in https://github.com/Microsoft/vscode/ + *-------------------------------------------------------------------------------------------------------*/ + +import { toDisposable, IDisposable, Disposable } from "./common/lifecycle"; + +class ThemingRegistry extends Disposable { + private theming: Theme[] = []; + + constructor() { + super(); + + this.theming = []; + } + + protected onThemeChange(theme: Theme): IDisposable { + this.theming.push(theme); + return toDisposable(() => { + const idx = this.theming.indexOf(theme); + this.theming.splice(idx, 1); + }); + } + + protected getTheming(): Theme[] { + return this.theming; + } +} + +export class Themebar extends ThemingRegistry { + + constructor() { + super(); + + // Titlebar + this.registerTheme((collector: CssStyle) => { + collector.addRule(` + .titlebar { + position: absolute; + top: 0; + left: 0; + right: 0; + box-sizing: border-box; + width: 100%; + font-size: 13px; + padding: 0 16px; + overflow: hidden; + flex-shrink: 0; + align-items: center; + justify-content: center; + user-select: none; + zoom: 1; + line-height: 22px; + height: 22px; + display: flex; + z-index: 99999; + } + + .titlebar.windows, .titlebar.linux { + padding: 0; + height: 30px; + line-height: 30px; + justify-content: left; + overflow: visible; + } + + .titlebar.inverted, .titlebar.inverted .menubar, + .titlebar.inverted .window-controls-container { + flex-direction: row-reverse; + } + + .titlebar.inverted .window-controls-container { + margin: 0 5px 0 0; + } + + .titlebar.first-buttons .window-controls-container { + order: -1; + margin: 0 5px 0 0; + } + `); + }); + + // Drag region + this.registerTheme((collector: CssStyle) => { + collector.addRule(` + .titlebar .titlebar-drag-region { + top: 0; + left: 0; + display: block; + position: absolute; + width: 100%; + height: 100%; + z-index: -1; + -webkit-app-region: drag; + } + `); + }); + + // icon app + this.registerTheme((collector: CssStyle) => { + collector.addRule(` + .titlebar > .window-appicon { + width: 35px; + height: 30px; + position: relative; + z-index: 99; + background-repeat: no-repeat; + background-position: center center; + background-size: 16px; + flex-shrink: 0; + } + `); + }); + + // Menubar + this.registerTheme((collector: CssStyle) => { + collector.addRule(` + .menubar { + display: flex; + flex-shrink: 1; + box-sizing: border-box; + height: 30px; + overflow: hidden; + flex-wrap: wrap; + } + + .menubar.bottom { + order: 1; + width: 100%; + padding: 0 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-button.disabled { + opacity: 0.4; + } + + .menubar .menubar-menu-button:not(.disabled):focus, + .menubar .menubar-menu-button:not(.disabled).open, + .menubar .menubar-menu-button:not(.disabled):hover { + background-color: rgba(255, 255, 255, .1); + } + + .titlebar.light .menubar .menubar-menu-button:focus, + .titlebar.light .menubar .menubar-menu-button.open, + .titlebar.light .menubar .menubar-menu-button:hover { + background-color: rgba(0, 0, 0, .1); + } + + .menubar-menu-container { + position: absolute; + display: block; + left: 0px; + opacity: 1; + outline: 0; + border: none; + text-align: left; + margin: 0 auto; + padding: .5em 0; + margin-left: 0; + overflow: visible; + justify-content: flex-end; + white-space: nowrap; + box-shadow: 0 5px 5px -3px rgba(0,0,0,.2), 0 8px 10px 1px rgba(0,0,0,.14), 0 3px 14px 2px rgba(0,0,0,.12); + z-index: 99999; + } + + .menubar-menu-container:focus { + outline: 0; + } + + .menubar-menu-container .action-item { + padding: 0; + transform: none; + display: -ms-flexbox; + display: flex; + outline: none; + } + + .menubar-menu-container .action-item.active { + transform: none; + } + + .menubar-menu-container .action-menu-item { + -ms-flex: 1 1 auto; + flex: 1 1 auto; + display: -ms-flexbox; + display: flex; + height: 2.5em; + align-items: center; + position: relative; + } + + .menubar-menu-container .action-label { + -ms-flex: 1 1 auto; + flex: 1 1 auto; + text-decoration: none; + padding: 0 1em; + background: none; + font-size: 12px; + line-height: 1; + } + + .menubar-menu-container .action-label:not(.separator), + .menubar-menu-container .keybinding { + padding: 0 2em 0 1em; + } + + .menubar-menu-container .keybinding, + .menubar-menu-container .submenu-indicator { + display: inline-block; + -ms-flex: 2 1 auto; + flex: 2 1 auto; + padding: 0 2em 0 1em; + text-align: right; + font-size: 12px; + line-height: 1; + } + + .menubar-menu-container .submenu-indicator { + height: 100%; + -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='14' height='14' viewBox='0 0 14 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.52 12.364L9.879 7 4.52 1.636l.615-.615L11.122 7l-5.986 5.98-.615-.616z' fill='%23000'/%3E%3C/svg%3E") no-repeat right center/13px 13px; + mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='14' height='14' viewBox='0 0 14 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.52 12.364L9.879 7 4.52 1.636l.615-.615L11.122 7l-5.986 5.98-.615-.616z' fill='%23000'/%3E%3C/svg%3E") no-repeat right center/13px 13px; + font-size: 60%; + margin: 0 2em 0 1em; + } + + .menubar-menu-container .action-item.disabled .action-menu-item, + .menubar-menu-container .action-label.separator { + opacity: 0.4; + } + + .menubar-menu-container .action-label:not(.separator) { + display: inline-block; + -webkit-box-sizing: border-box; + -o-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + margin: 0; + } + + .menubar-menu-container .action-item .submenu { + position: absolute; + } + + .menubar-menu-container .action-label.separator { + font-size: inherit; + padding: .2em 0 0; + margin-left: .8em; + margin-right: .8em; + margin-bottom: .2em; + width: 100%; + border-bottom: 1px solid transparent; + } + + .menubar-menu-container .action-label.separator.text { + padding: 0.7em 1em 0.1em 1em; + font-weight: bold; + opacity: 1; + } + + .menubar-menu-container .action-label:hover { + color: inherit; + } + + .menubar-menu-container .menu-item-check { + position: absolute; + visibility: hidden; + -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='-2 -2 16 16'%3E%3Cpath fill='%23424242' d='M9 0L4.5 9 3 6H0l3 6h3l6-12z'/%3E%3C/svg%3E") no-repeat 50% 56%/15px 15px; + mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='-2 -2 16 16'%3E%3Cpath fill='%23424242' d='M9 0L4.5 9 3 6H0l3 6h3l6-12z'/%3E%3C/svg%3E") no-repeat 50% 56%/15px 15px; + width: 2em; + height: 2em; + margin: 0 0 0 0.7em; + } + + .menubar-menu-container .menu-item-icon { + width: 18px; + height: 18px; + margin: 0 0 0 1.1em; + } + + .menubar-menu-container .menu-item-icon img { + display: inherit; + width: 100%; + height: 100%; + } + + .menubar-menu-container .action-menu-item.checked .menu-item-icon { + visibility: hidden; + } + + .menubar-menu-container .action-menu-item.checked .menu-item-check { + visibility: visible; + } + `); + }); + + // Title + this.registerTheme((collector: CssStyle) => { + collector.addRule(` + .titlebar .window-title { + flex: 0 1 auto; + font-size: 12px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0 auto; + zoom: 1; + } + `); + }); + + // Window controls + this.registerTheme((collector: CssStyle) => { + collector.addRule(` + .titlebar .window-controls-container { + display: flex; + flex-grow: 0; + flex-shrink: 0; + text-align: center; + position: relative; + z-index: 99; + -webkit-app-region: no-drag; + height: 30px; + margin-left: 5px; + } + `); + }); + + // Resizer + this.registerTheme((collector: CssStyle) => { + collector.addRule(` + .titlebar.windows .resizer, .titlebar.linux .resizer { + -webkit-app-region: no-drag; + position: absolute; + } + + .titlebar.windows .resizer.top, .titlebar.linux .resizer.top { + top: 0; + width: 100%; + height: 6px; + } + + .titlebar.windows .resizer.left, .titlebar.linux .resizer.left { + top: 0; + left: 0; + width: 6px; + height: 100%; + } + `); + }); + } + + protected registerTheme(theme: Theme) { + this.onThemeChange(theme); + + let cssRules: string[] = []; + let hasRule: { [rule: string]: boolean } = {}; + let ruleCollector = { + addRule: (rule: string) => { + if (!hasRule[rule]) { + cssRules.push(rule); + hasRule[rule] = true; + } + } + }; + + this.getTheming().forEach(p => p(ruleCollector)); + + _applyRules(cssRules.join('\n'), 'titlebar-style'); + } + + static get win(): Theme { + return ((collector: CssStyle) => { + collector.addRule(` + .titlebar .window-controls-container .window-icon-bg { + display: inline-block; + -webkit-app-region: no-drag; + height: 100%; + width: 46px; + } + + .titlebar .window-controls-container .window-icon-bg .window-icon { + height: 100%; + width: 100%; + -webkit-mask-size: 23.1% !important; + mask-size: 23.1% !important; + } + + .titlebar .window-controls-container .window-icon-bg .window-icon.window-close { + -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='11' viewBox='0 0 11 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.279 5.5L11 10.221l-.779.779L5.5 6.279.779 11 0 10.221 4.721 5.5 0 .779.779 0 5.5 4.721 10.221 0 11 .779 6.279 5.5z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='11' viewBox='0 0 11 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.279 5.5L11 10.221l-.779.779L5.5 6.279.779 11 0 10.221 4.721 5.5 0 .779.779 0 5.5 4.721 10.221 0 11 .779 6.279 5.5z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + } + + .titlebar .window-controls-container .window-icon-bg .window-icon.window-close:hover { + background-color: #ffffff!important; + } + + .titlebar .window-controls-container .window-icon-bg .window-icon.window-unmaximize { + -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='11' viewBox='0 0 11 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 8.798H8.798V11H0V2.202h2.202V0H11v8.798zm-3.298-5.5h-6.6v6.6h6.6v-6.6zM9.9 1.1H3.298v1.101h5.5v5.5h1.1v-6.6z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='11' viewBox='0 0 11 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 8.798H8.798V11H0V2.202h2.202V0H11v8.798zm-3.298-5.5h-6.6v6.6h6.6v-6.6zM9.9 1.1H3.298v1.101h5.5v5.5h1.1v-6.6z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + } + + .titlebar .window-controls-container .window-icon-bg .window-icon.window-maximize { + -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='11' viewBox='0 0 11 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 0v11H0V0h11zM9.899 1.101H1.1V9.9h8.8V1.1z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='11' viewBox='0 0 11 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 0v11H0V0h11zM9.899 1.101H1.1V9.9h8.8V1.1z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + } + + .titlebar .window-controls-container .window-icon-bg .window-icon.window-minimize { + -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='11' viewBox='0 0 11 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 4.399V5.5H0V4.399h11z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='11' height='11' viewBox='0 0 11 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 4.399V5.5H0V4.399h11z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + } + + .titlebar .window-controls-container .window-icon-bg.window-close-bg:hover { + background-color: rgba(232, 17, 35, 0.9)!important; + } + + .titlebar .window-controls-container .window-icon-bg.inactive { + background-color: transparent!important; + } + + .titlebar .window-controls-container .window-icon-bg.inactive .window-icon { + opacity: .4; + } + + .titlebar .window-controls-container .window-icon { + background-color: #eeeeee; + } + + .titlebar.light .window-controls-container .window-icon { + background-color: #333333; + } + + .titlebar.inactive .window-controls-container .window-icon { + opacity: .7; + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive):hover { + background-color: rgba(255, 255, 255, .1); + } + + .titlebar.light .window-controls-container .window-icon-bg:not(.inactive):hover { + background-color: rgba(0, 0, 0, .1); + } + `); + }); + } + + static get mac(): Theme { + return ((collector: CssStyle) => { + collector.addRule(` + .titlebar .window-controls-container .window-icon-bg { + display: inline-block; + -webkit-app-region: no-drag; + height: 15px; + width: 15px; + margin: 7.5px 6px; + border-radius: 50%; + overflow: hidden; + } + + .titlebar .window-controls-container .window-icon-bg.inactive { + background-color: #cdcdcd; + } + + .titlebar .window-controls-container .window-icon-bg:nth-child(2n) { + order: -1; + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive) .window-icon { + height: 100%; + width: 100%; + background-color: transparent; + -webkit-mask-size: 100% !important; + mask-size: 100% !important; + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive) .window-icon:hover { + background-color: rgba(0, 0, 0, 0.4); + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive):first-child { + background-color: #febc28; + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive):first-child:hover { + background-color: #feb30a; + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive):nth-child(2n) { + background-color: #01cc4e; + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive):nth-child(2n):hover { + background-color: #01ae42; + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive).window-close-bg { + background-color: #ff5b5d; + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive).window-close-bg:hover { + background-color: #ff3c3f; + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive).window-close-bg .window-close { + -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive) .window-maximize { + -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.17,12L15,8.83L16.41,7.41L21,12L16.41,16.58L15,15.17L18.17,12M5.83,12L9,15.17L7.59,16.59L3,12L7.59,7.42L9,8.83L5.83,12Z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.17,12L15,8.83L16.41,7.41L21,12L16.41,16.58L15,15.17L18.17,12M5.83,12L9,15.17L7.59,16.59L3,12L7.59,7.42L9,8.83L5.83,12Z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + transform: rotate(-45deg); + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive) .window-unmaximize { + -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5.41,7.41L10,12L5.41,16.59L4,15.17L7.17,12L4,8.83L5.41,7.41M18.59,16.59L14,12L18.59,7.42L20,8.83L16.83,12L20,15.17L18.59,16.59Z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5.41,7.41L10,12L5.41,16.59L4,15.17L7.17,12L4,8.83L5.41,7.41M18.59,16.59L14,12L18.59,7.42L20,8.83L16.83,12L20,15.17L18.59,16.59Z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + transform: rotate(-45deg); + } + + .titlebar .window-controls-container .window-icon-bg:not(.inactive) .window-minimize { + -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M19,13H5V11H19V13Z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + mask: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M19,13H5V11H19V13Z' fill='%23000'/%3E%3C/svg%3E") no-repeat 50% 50%; + } + `); + }); + } + +} + +export interface CssStyle { + addRule(rule: string): void; +} + +export interface Theme { + (collector: CssStyle): void; +} + +function _applyRules(styleSheetContent: string, rulesClassName: string) { + let themeStyles = document.head.getElementsByClassName(rulesClassName); + + if (themeStyles.length === 0) { + let styleElement = document.createElement('style'); + styleElement.type = 'text/css'; + styleElement.className = rulesClassName; + styleElement.innerHTML = styleSheetContent; + document.head.appendChild(styleElement); + } else { + (themeStyles[0]).innerHTML = styleSheetContent; + } +} diff --git a/src/main/custom-electron-titlebar/titlebar.ts b/src/main/custom-electron-titlebar/titlebar.ts new file mode 100644 index 0000000..145334b --- /dev/null +++ b/src/main/custom-electron-titlebar/titlebar.ts @@ -0,0 +1,585 @@ +/*-------------------------------------------------------------------------------------------------------- + * This file has been modified by @AlexTorresSk (http://github.com/AlexTorresSk) + * to work in custom-electron-titlebar. + * + * The original copy of this file and its respective license are in https://github.com/Microsoft/vscode/ + * + * Copyright (c) 2018 Alex Torres + * Licensed under the MIT License. See License in the project root for license information. + *-------------------------------------------------------------------------------------------------------*/ + +import { isMacintosh, isWindows, isLinux } from './common/platform'; +import { Color, RGBA } from './common/color'; +import { EventType, hide, show, removeClass, addClass, append, $, addDisposableListener, prepend, removeNode } from './common/dom'; +import { Menubar, MenubarOptions } from './menubar'; +import { remote, BrowserWindow } from 'electron'; +import { Theme, Themebar } from './themebar'; + +const INACTIVE_FOREGROUND_DARK = Color.fromHex('#222222'); +const ACTIVE_FOREGROUND_DARK = Color.fromHex('#333333'); +const INACTIVE_FOREGROUND = Color.fromHex('#EEEEEE'); +const ACTIVE_FOREGROUND = Color.fromHex('#FFFFFF'); + +const BOTTOM_TITLEBAR_HEIGHT = '60px'; +const TOP_TITLEBAR_HEIGHT_MAC = '22px'; +const TOP_TITLEBAR_HEIGHT_WIN = '30px'; + +export interface TitlebarOptions extends MenubarOptions { + /** + * The background color of titlebar. + */ + backgroundColor: Color; + /** + * The icon shown on the left side of titlebar. + */ + icon?: string; + /** + * Style of the icons of titlebar. + * You can create your custom style using [`Theme`](https://github.com/AlexTorresSk/custom-electron-titlebar/THEMES.md) + */ + iconsTheme?: Theme; + /** + * The shadow color of titlebar. + */ + shadow?: boolean; + /** + * Define if the minimize window button is displayed. + * *The default is true* + */ + minimizable?: boolean; + /** + * Define if the maximize and restore window buttons are displayed. + * *The default is true* + */ + maximizable?: boolean; + /** + * Define if the close window button is displayed. + * *The default is true* + */ + closeable?: boolean; + /** + * When the close button is clicked, the window is hidden instead of closed. + * *The default is false* + */ + hideWhenClickingClose?: boolean; + /** + * Enables or disables the blur option in titlebar. + * *The default is true* + */ + unfocusEffect?: boolean; + /** + * Set the order of the elements on the title bar. You can use `inverted`, `first-buttons` or don't add for. + * *The default is normal* + */ + order?: "inverted" | "first-buttons"; + /** + * Set horizontal alignment of the window title. + * *The default value is center* + */ + titleHorizontalAlignment?: "left" | "center" | "right"; + /** + * Sets the value for the overflow of the window. + * *The default value is auto* + */ + overflow?: "auto" | "hidden" | "visible"; +} + +const defaultOptions: TitlebarOptions = { + backgroundColor: Color.fromHex('#444444'), + iconsTheme: Themebar.win, + shadow: false, + menu: remote.Menu.getApplicationMenu(), + minimizable: true, + maximizable: true, + closeable: true, + enableMnemonics: true, + hideWhenClickingClose: false, + unfocusEffect: true, + overflow: "auto", +}; + +export class Titlebar extends Themebar { + + private titlebar: HTMLElement; + private title: HTMLElement; + private dragRegion: HTMLElement; + private appIcon: HTMLElement; + private menubarContainer: HTMLElement; + private windowControls: HTMLElement; + private maxRestoreControl: HTMLElement; + private container: HTMLElement; + + private resizer: { + top: HTMLElement; + left: HTMLElement; + } + + private isInactive: boolean; + + private currentWindow: BrowserWindow; + private _options: TitlebarOptions; + private menubar: Menubar; + + private events: { [k: string]: Function; }; + + constructor(options?: TitlebarOptions) { + super(); + + this.currentWindow = remote.getCurrentWindow(); + + this._options = { ...defaultOptions, ...options }; + + this.registerListeners(); + this.createTitlebar(); + this.updateStyles(); + this.registerTheme(this._options.iconsTheme); + + window.addEventListener('beforeunload', () => { + this.removeListeners(); + }); + } + + private closeMenu = () => { + if (this.menubar) { + this.menubar.blur(); + } + } + + private registerListeners() { + this.events = {}; + + this.events[EventType.FOCUS] = () => this.onDidChangeWindowFocus(true); + this.events[EventType.BLUR] = () => this.onDidChangeWindowFocus(false); + this.events[EventType.MAXIMIZE] = () => this.onDidChangeMaximized(true); + this.events[EventType.UNMAXIMIZE] = () => this.onDidChangeMaximized(false); + this.events[EventType.ENTER_FULLSCREEN] = () => this.onDidChangeFullscreen(true); + this.events[EventType.LEAVE_FULLSCREEN] = () => this.onDidChangeFullscreen(false); + + for (const k in this.events) { + this.currentWindow.on(k as any, this.events[k]); + } + } + + // From https://github.com/panjiang/custom-electron-titlebar/commit/825bff6b15e9223c1160208847b4c5010610bcf7 + private removeListeners() { + for (const k in this.events) { + this.currentWindow.removeListener(k as any, this.events[k]); + } + + this.events = {}; + } + + private createTitlebar() { + // Content container + this.container = $('div.container-after-titlebar'); + if (this._options.menuPosition === 'bottom') { + this.container.style.top = BOTTOM_TITLEBAR_HEIGHT; + this.container.style.bottom = '0px'; + } else { + this.container.style.top = isMacintosh ? TOP_TITLEBAR_HEIGHT_MAC : TOP_TITLEBAR_HEIGHT_WIN; + this.container.style.bottom = '0px'; + } + this.container.style.right = '0'; + this.container.style.left = '0'; + this.container.style.position = 'absolute'; + this.container.style.overflow = this._options.overflow; + + while (document.body.firstChild) { + append(this.container, document.body.firstChild); + } + + append(document.body, this.container); + + document.body.style.overflow = 'hidden'; + document.body.style.margin = '0'; + + // Titlebar + this.titlebar = $('div.titlebar'); + addClass(this.titlebar, isWindows ? 'windows' : isLinux ? 'linux' : 'mac'); + + if (this._options.order) { + addClass(this.titlebar, this._options.order); + } + + if (this._options.shadow) { + this.titlebar.style.boxShadow = `0 2px 1px -1px rgba(0, 0, 0, .2), 0 1px 1px 0 rgba(0, 0, 0, .14), 0 1px 3px 0 rgba(0, 0, 0, .12)`; + } + + this.dragRegion = append(this.titlebar, $('div.titlebar-drag-region')); + + // App Icon (Windows/Linux) + if (!isMacintosh && this._options.icon) { + this.appIcon = append(this.titlebar, $('div.window-appicon')); + this.updateIcon(this._options.icon); + } + + // Menubar + this.menubarContainer = append(this.titlebar, $('div.menubar')); + this.menubarContainer.setAttribute('role', 'menubar'); + + if (this._options.menu) { + this.updateMenu(this._options.menu); + this.updateMenuPosition(this._options.menuPosition); + } + + // Title + this.title = append(this.titlebar, $('div.window-title')); + + if (!isMacintosh) { + this.title.style.cursor = 'default'; + } + + this.updateTitle(); + this.setHorizontalAlignment(this._options.titleHorizontalAlignment); + + // Maximize/Restore on doubleclick + if (isMacintosh) { + let isMaximized = this.currentWindow.isMaximized(); + this._register(addDisposableListener(this.titlebar, EventType.DBLCLICK, () => { + isMaximized = !isMaximized; + this.onDidChangeMaximized(isMaximized); + })); + } + + // Window Controls (Windows/Linux) + if (!isMacintosh) { + this.windowControls = append(this.titlebar, $('div.window-controls-container')); + + // Minimize + const minimizeIconContainer = append(this.windowControls, $('div.window-icon-bg')); + const minimizeIcon = append(minimizeIconContainer, $('div.window-icon')); + addClass(minimizeIcon, 'window-minimize'); + + if (!this._options.minimizable) { + addClass(minimizeIconContainer, 'inactive'); + } else { + this._register(addDisposableListener(minimizeIcon, EventType.CLICK, e => { + this.currentWindow.minimize(); + })); + } + + // Restore + const restoreIconContainer = append(this.windowControls, $('div.window-icon-bg')); + this.maxRestoreControl = append(restoreIconContainer, $('div.window-icon')); + addClass(this.maxRestoreControl, 'window-max-restore'); + + if (!this._options.maximizable) { + addClass(restoreIconContainer, 'inactive'); + } else { + this._register(addDisposableListener(this.maxRestoreControl, EventType.CLICK, e => { + if (this.currentWindow.isMaximized()) { + this.currentWindow.unmaximize(); + this.onDidChangeMaximized(false); + } else { + this.currentWindow.maximize(); + this.onDidChangeMaximized(true); + } + })); + } + + // Close + const closeIconContainer = append(this.windowControls, $('div.window-icon-bg')); + addClass(closeIconContainer, 'window-close-bg'); + const closeIcon = append(closeIconContainer, $('div.window-icon')); + addClass(closeIcon, 'window-close'); + + if (!this._options.closeable) { + addClass(closeIconContainer, 'inactive'); + } else { + this._register(addDisposableListener(closeIcon, EventType.CLICK, e => { + if (this._options.hideWhenClickingClose) { + this.currentWindow.hide() + } else { + this.currentWindow.close() + } + })); + } + + // Resizer + this.resizer = { + top: append(this.titlebar, $('div.resizer.top')), + left: append(this.titlebar, $('div.resizer.left')) + } + + this.onDidChangeMaximized(this.currentWindow.isMaximized()); + } + + prepend(document.body, this.titlebar); + } + + private onBlur(): void { + this.isInactive = true; + this.updateStyles(); + } + + private onFocus(): void { + this.isInactive = false; + this.updateStyles(); + } + + private onMenubarVisibilityChanged(visible: boolean) { + if (isWindows || isLinux) { + // Hide title when toggling menu bar + if (visible) { + // Hack to fix issue #52522 with layered webkit-app-region elements appearing under cursor + hide(this.dragRegion); + setTimeout(() => show(this.dragRegion), 50); + } + } + } + + private onMenubarFocusChanged(focused: boolean) { + if (isWindows || isLinux) { + if (focused) { + hide(this.dragRegion); + } else { + show(this.dragRegion); + } + } + } + + private onDidChangeWindowFocus(hasFocus: boolean): void { + if (this.titlebar) { + if (hasFocus) { + removeClass(this.titlebar, 'inactive'); + this.onFocus(); + } else { + addClass(this.titlebar, 'inactive'); + this.closeMenu(); + this.onBlur(); + } + } + } + + private onDidChangeMaximized(maximized: boolean) { + if (this.maxRestoreControl) { + if (maximized) { + removeClass(this.maxRestoreControl, 'window-maximize'); + addClass(this.maxRestoreControl, 'window-unmaximize'); + } else { + removeClass(this.maxRestoreControl, 'window-unmaximize'); + addClass(this.maxRestoreControl, 'window-maximize'); + } + } + + if (this.resizer) { + if (maximized) { + hide(this.resizer.top, this.resizer.left); + } else { + show(this.resizer.top, this.resizer.left); + } + } + } + + private onDidChangeFullscreen(fullscreen: boolean) { + if (!isMacintosh) { + if (fullscreen) { + hide(this.appIcon, this.title, this.windowControls); + } else { + show(this.appIcon, this.title, this.windowControls); + } + } + } + + private updateStyles() { + if (this.titlebar) { + if (this.isInactive) { + addClass(this.titlebar, 'inactive'); + } else { + removeClass(this.titlebar, 'inactive'); + } + + const titleBackground = this.isInactive && this._options.unfocusEffect + ? this._options.backgroundColor.lighten(.45) + : this._options.backgroundColor; + + this.titlebar.style.backgroundColor = titleBackground.toString(); + + let titleForeground: Color; + + if (titleBackground.isLighter()) { + addClass(this.titlebar, 'light'); + + titleForeground = this.isInactive && this._options.unfocusEffect + ? INACTIVE_FOREGROUND_DARK + : ACTIVE_FOREGROUND_DARK; + } else { + removeClass(this.titlebar, 'light'); + + titleForeground = this.isInactive && this._options.unfocusEffect + ? INACTIVE_FOREGROUND + : ACTIVE_FOREGROUND; + } + + this.titlebar.style.color = titleForeground.toString(); + + const backgroundColor = this._options.backgroundColor.darken(.16); + + const foregroundColor = backgroundColor.isLighter() + ? INACTIVE_FOREGROUND_DARK + : INACTIVE_FOREGROUND; + + const bgColor = !this._options.itemBackgroundColor || this._options.itemBackgroundColor.equals(backgroundColor) + ? new Color(new RGBA(0, 0, 0, .14)) + : this._options.itemBackgroundColor; + + const fgColor = bgColor.isLighter() ? ACTIVE_FOREGROUND_DARK : ACTIVE_FOREGROUND; + + if (this.menubar) { + this.menubar.setStyles({ + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + selectionBackgroundColor: bgColor, + selectionForegroundColor: fgColor, + separatorColor: foregroundColor + }); + } + } + } + + /** + * get the options of the titlebar + */ + public get options(): TitlebarOptions { + return this._options; + } + + /** + * Update the background color of the title bar + * @param backgroundColor The color for the background + */ + updateBackground(backgroundColor: Color): void { + this._options.backgroundColor = backgroundColor; + this.updateStyles(); + } + + /** + * Update the item background color of the menubar + * @param itemBGColor The color for the item background + */ + updateItemBGColor(itemBGColor: Color): void { + this._options.itemBackgroundColor = itemBGColor; + this.updateStyles(); + } + + /** + * Update the title of the title bar. + * You can use this method if change the content of `` tag on your html. + * @param title The title of the title bar and document. + */ + updateTitle(title?: string) { + if (this.title) { + if (title) { + document.title = title; + } else { + title = document.title; + } + + this.title.innerText = title; + } + } + + /** + * It method set new icon to title-bar-icon of title-bar. + * @param path path to icon + */ + updateIcon(path: string) { + if (path === null || path === '') { + return; + } + + if (this.appIcon) { + this.appIcon.style.backgroundImage = `url("${path}")`; + } + } + + /** + * Update the default menu or set a new menu. + * @param menu The menu. + */ + // Menu enhancements, moved menu to bottom of window-titlebar. (by @MairwunNx) https://github.com/AlexTorresSk/custom-electron-titlebar/pull/9 + updateMenu(menu: Electron.Menu) { + if (!isMacintosh) { + if (this.menubar) { + this.menubar.dispose(); + this._options.menu = menu; + } + + this.menubar = new Menubar(this.menubarContainer, this._options, this.closeMenu); + this.menubar.setupMenubar(); + + this._register(this.menubar.onVisibilityChange(e => this.onMenubarVisibilityChanged(e))); + this._register(this.menubar.onFocusStateChange(e => this.onMenubarFocusChanged(e))); + + this.updateStyles(); + } else { + remote.Menu.setApplicationMenu(menu); + } + } + + /** + * Update the position of menubar. + * @param menuPosition The position of the menu `left` or `bottom`. + */ + updateMenuPosition(menuPosition: "left" | "bottom") { + this._options.menuPosition = menuPosition; + if (isMacintosh) { + this.titlebar.style.height = this._options.menuPosition && this._options.menuPosition === 'bottom' ? BOTTOM_TITLEBAR_HEIGHT : TOP_TITLEBAR_HEIGHT_MAC; + this.container.style.top = this._options.menuPosition && this._options.menuPosition === 'bottom' ? BOTTOM_TITLEBAR_HEIGHT : TOP_TITLEBAR_HEIGHT_MAC; + } else { + this.titlebar.style.height = this._options.menuPosition && this._options.menuPosition === 'bottom' ? BOTTOM_TITLEBAR_HEIGHT : TOP_TITLEBAR_HEIGHT_WIN; + this.container.style.top = this._options.menuPosition && this._options.menuPosition === 'bottom' ? BOTTOM_TITLEBAR_HEIGHT : TOP_TITLEBAR_HEIGHT_WIN; + } + this.titlebar.style.webkitFlexWrap = this._options.menuPosition && this._options.menuPosition === 'bottom' ? 'wrap' : null; + + if (this._options.menuPosition === 'bottom') { + addClass(this.menubarContainer, 'bottom'); + } else { + removeClass(this.menubarContainer, 'bottom'); + } + } + + /** + * Horizontal alignment of the title. + * @param side `left`, `center` or `right`. + */ + // Add ability to customize title-bar title. (by @MairwunNx) https://github.com/AlexTorresSk/custom-electron-titlebar/pull/8 + setHorizontalAlignment(side: "left" | "center" | "right") { + if (this.title) { + if (side === 'left' || (side === 'right' && this._options.order === 'inverted')) { + this.title.style.marginLeft = '8px'; + this.title.style.marginRight = 'auto'; + } + + if (side === 'right' || (side === 'left' && this._options.order === 'inverted')) { + this.title.style.marginRight = '8px'; + this.title.style.marginLeft = 'auto'; + } + + if (side === 'center' || side === undefined) { + this.title.style.marginRight = 'auto'; + this.title.style.marginLeft = 'auto'; + } + } + } + + /** + * Remove the titlebar, menubar and all methods. + */ + dispose() { + if (this.menubar) this.menubar.dispose(); + + removeNode(this.titlebar); + + while (this.container.firstChild) { + append(document.body, this.container.firstChild); + } + + removeNode(this.container); + + this.removeListeners(); + + super.dispose(); + } + +} diff --git a/src/main/key-bindings/index.ts b/src/main/key-bindings/index.ts new file mode 100644 index 0000000..624e9b4 --- /dev/null +++ b/src/main/key-bindings/index.ts @@ -0,0 +1,2 @@ +export * from './key-bindings'; +export * from './shortcut'; diff --git a/src/main/key-bindings/key-bindings.ts b/src/main/key-bindings/key-bindings.ts new file mode 100644 index 0000000..f293dc9 --- /dev/null +++ b/src/main/key-bindings/key-bindings.ts @@ -0,0 +1,5 @@ +export class KeyBindings { + constructor() { + + } +} diff --git a/src/main/key-bindings/shortcut.ts b/src/main/key-bindings/shortcut.ts new file mode 100644 index 0000000..198645a --- /dev/null +++ b/src/main/key-bindings/shortcut.ts @@ -0,0 +1,5 @@ +export type ShortcutItem = { + accelerator: string; + click: () => void; + id: string; +}; diff --git a/src/main/menu/handlers/edit.ts b/src/main/menu/handlers/edit.ts new file mode 100644 index 0000000..4e0619f --- /dev/null +++ b/src/main/menu/handlers/edit.ts @@ -0,0 +1,8 @@ +import { BrowserWindow } from 'electron'; +import { IpcChannelName } from '../../../common'; + +export function edit(win: BrowserWindow, arg) { + if (win && win.webContents) { + win.webContents.send(IpcChannelName.MR_FILE_WINDOW, arg); + } +} diff --git a/src/main/menu/menu.ts b/src/main/menu/menu.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/main/menu/templates/edit.ts b/src/main/menu/templates/edit.ts new file mode 100644 index 0000000..1a8ef4c --- /dev/null +++ b/src/main/menu/templates/edit.ts @@ -0,0 +1,35 @@ +import { MenuItemConstructorOptions } from 'electron'; +import { KeyBindings } from '../../key-bindings'; +import { I18n } from '../../i18n'; +import { I18nTextKey, IpcType } from '../../../common'; +import * as handler from '../handlers/edit'; + +export function editMenu( + keybindings: KeyBindings, + i18n: I18n +): MenuItemConstructorOptions { + const t = key => i18n.t(key); + return { + label: t(I18nTextKey.EDIT), + submenu: [ + { + label: t(I18nTextKey.UNDO), + accelerator: 'CmdOrCtrl+Z', + click(menu, win) { + handler.edit(win, { + type: IpcType.MR_UNDO + }); + } + }, + { + label: t(I18nTextKey.REDO), + accelerator: 'CmdOrCtrl+Shift+Z', + click(menu, win) { + handler.edit(win, { + type: IpcType.MR_REDO + }); + } + } + ] + }; +} diff --git a/src/main/utils/feature-switch.ts b/src/main/utils/feature-switch.ts new file mode 100644 index 0000000..71b3714 --- /dev/null +++ b/src/main/utils/feature-switch.ts @@ -0,0 +1,5 @@ +export class FeatureSwitch { + static isUseCustomTitleBar() : boolean { + return true; + } +} diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index b285bc5..684cf5c 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -2,3 +2,4 @@ export * from './platform'; export * from './recent'; export * from './utils'; export * from './file-system'; +export * from './feature-switch'; diff --git a/src/main/utils/utils.ts b/src/main/utils/utils.ts index 1ead7b8..b595fa3 100644 --- a/src/main/utils/utils.ts +++ b/src/main/utils/utils.ts @@ -1,6 +1,11 @@ import { BrowserWindow } from 'electron'; import { extname } from 'path'; -import { BlinkMindExtName, BlinkMindExtNames, I18nTextKey, IpcChannelName } from '../../common'; +import { + BlinkMindExtName, + BlinkMindExtNames, + I18nTextKey, + IpcChannelName +} from '../../common'; import { i18n } from '../i18n'; export function regularBlinkPath(path) { @@ -19,9 +24,8 @@ export function getFileTitle(path, edited) { return `${path}${edited ? ' -' + i18n.t(I18nTextKey.EDITED) : ''}`; } -export function ipcMR(arg) { - const focusWindow = BrowserWindow.getFocusedWindow(); - focusWindow.webContents.send(IpcChannelName.MR_FILE_WINDOW, arg); +export function ipcMR(arg, win = BrowserWindow.getFocusedWindow()) { + win.webContents.send(IpcChannelName.MR_FILE_WINDOW, arg); } export function isDev() { diff --git a/src/main/window/file-window-preload.ts b/src/main/window/file-window-preload.ts index 7421555..6496919 100644 --- a/src/main/window/file-window-preload.ts +++ b/src/main/window/file-window-preload.ts @@ -1,10 +1,10 @@ import './preload'; -import { isMacOS, isWindows } from '../utils'; +import { isMacOS, isWindows, FeatureSwitch } from '../utils'; window.addEventListener('DOMContentLoaded', event => { - if (isWindows) { + if (isWindows && FeatureSwitch.isUseCustomTitleBar()) { console.log('titlebar'); - const customTitlebar = require('custom-electron-titlebar'); + const customTitlebar = require('../custom-electron-titlebar'); new customTitlebar.Titlebar({ backgroundColor: customTitlebar.Color.fromHex('#fff'), menuPosition: 'right' diff --git a/src/main/window/main-menu.ts b/src/main/window/main-menu.ts index acc5f67..556621a 100644 --- a/src/main/window/main-menu.ts +++ b/src/main/window/main-menu.ts @@ -1,4 +1,4 @@ -import { Menu, shell } from 'electron'; +import { Menu, shell, BrowserWindow } from 'electron'; import { I18nTextKey, IpcChannelName, @@ -9,7 +9,6 @@ import { import { isMacOS, isWindows, IsDev } from '../utils'; import { openFile, redo, save, saveAs, undo } from './menu-event-handler'; import { subscribeMgr } from '../subscribe'; -import BrowserWindow = Electron.BrowserWindow; const log = require('debug')('bmd:menu'); @@ -53,6 +52,7 @@ function getMenu(i18n, windowMgr) { label: t(I18nTextKey.SAVE), accelerator: 'CmdOrCtrl+S', click() { + console.log('save'); save(windowMgr); } }, @@ -98,27 +98,30 @@ function getMenu(i18n, windowMgr) { role: 'cut' }, { - label: t(I18nTextKey.PASTE_AS_PLAIN_TEXT), + label: t(I18nTextKey.PASTE), // role: 'paste as plaintext', accelerator: 'CmdOrCtrl+V', - click(menuItem, browserWindow: BrowserWindow) { - log('PASTE_AS_PLAIN_TEXT'); - browserWindow.webContents.send(IpcChannelName.MR_FILE_WINDOW, { - type: IpcType.MR_PASTE, - pasteType: PasteType.PASTE_PLAIN_TEXT - }); - } + role: 'paste', + // click(menuItem, browserWindow: BrowserWindow) { + // console.log('PASTE_AS_PLAIN_TEXT'); + // browserWindow.webContents.send(IpcChannelName.MR_FILE_WINDOW, { + // type: IpcType.MR_PASTE, + // pasteType: PasteType.PASTE_PLAIN_TEXT + // }); + // } }, { - label: t(I18nTextKey.PASTE_WITH_STYLE), + label: t(I18nTextKey.PASTE_AS_PLAIN_TEXT), // role: 'paste as plaintext', accelerator: 'CmdOrCtrl+Shift+V', - click(menuItem, browserWindow: BrowserWindow) { - browserWindow.webContents.send(IpcChannelName.MR_FILE_WINDOW, { - type: IpcType.MR_PASTE, - pasteType: PasteType.PASTE_WITH_STYLE - }); - } + role: 'pasteAndMatchStyle', + // click(menuItem, browserWindow: BrowserWindow) { + // console.log('PASTE_WITH_STYLE'); + // browserWindow.webContents.send(IpcChannelName.MR_FILE_WINDOW, { + // type: IpcType.MR_PASTE, + // pasteType: PasteType.PASTE_WITH_STYLE + // }); + // } } ] }; diff --git a/src/main/window/window-manager.ts b/src/main/window/window-manager.ts index 7577e4e..f1f150a 100644 --- a/src/main/window/window-manager.ts +++ b/src/main/window/window-manager.ts @@ -23,6 +23,7 @@ import { getUntitledTile, isMacOS, regularBlinkPath, + FeatureSwitch, isWindows } from '../utils'; import { buildMenu } from './main-menu'; @@ -273,8 +274,8 @@ export class WindowMgr { preload: join(__dirname, './file-window-preload'), scrollBounce: true }, - frame: false, - titleBarStyle: 'hidden', + frame: !FeatureSwitch.isUseCustomTitleBar(), + titleBarStyle: FeatureSwitch.isUseCustomTitleBar() ? 'hidden' : 'default', //isMacOS ? 'hidden' : 'default', title: path == null ? getUntitledTile() : path }); diff --git a/src/renderer/components/mindmap.tsx b/src/renderer/components/mindmap.tsx index d29485e..13fb0c0 100644 --- a/src/renderer/components/mindmap.tsx +++ b/src/renderer/components/mindmap.tsx @@ -2,6 +2,7 @@ import { Diagram } from '@blink-mind/renderer-react'; import debug from 'debug'; import * as React from 'react'; import { FileModel } from '../models'; +import { useEffect } from 'react'; const log = debug('bmd:mindmap'); @@ -9,27 +10,40 @@ interface Props { fileModel: FileModel; } -export class MindMap extends React.Component<Props> { - createDocModel(fileModel: FileModel) { +export function MindMap(props: Props) { + const { fileModel } = props; + let { controller, docModel } = fileModel; + + const createDocModel = (fileModel: FileModel) => { if (fileModel.path == null) { return fileModel.controller.run('createNewDocModel'); } return null; - } + }; + + docModel = docModel || createDocModel(fileModel); - renderDiagram() { - const { fileModel } = this.props; + // const handlePaste = e => { + // console.log('handlePaste'); + // controller.run('setPasteType', 'PASTE_PLAIN_TEXT'); + // document.execCommand('paste'); + // }; + // + // useEffect(() => { + // document.addEventListener('paste', handlePaste); + // return () => { + // document.removeEventListener('paste', handlePaste); + // }; + // }); - const docModel = fileModel.docModel || this.createDocModel(fileModel); - log('renderDiagram', docModel,docModel.currentSheetModel.focusKey); + const renderDiagram = () => { + log('renderDiagram', docModel, docModel.currentSheetModel.focusKey); const diagramProps = { - controller: fileModel.controller, + controller, docModel }; return <Diagram {...diagramProps} />; - } + }; - render() { - return <div className="mindmap">{this.renderDiagram()}</div>; - } + return <div className="mindmap">{renderDiagram()}</div>; } diff --git a/src/renderer/pages/files-page.tsx b/src/renderer/pages/files-page.tsx index 2aa0aeb..ff62454 100644 --- a/src/renderer/pages/files-page.tsx +++ b/src/renderer/pages/files-page.tsx @@ -19,6 +19,8 @@ const handleElementClick = e => { } }; + + export function FilesPage(props) { const t = useTranslation(); //@ts-ignore @@ -27,9 +29,9 @@ export function FilesPage(props) { const [windowData] = useState(initWindowData); useEffect(() => { - document.body.addEventListener('click', handleElementClick); + document.addEventListener('click', handleElementClick); return () => { - document.body.removeEventListener('click', handleElementClick); + document.removeEventListener('click', handleElementClick); }; }); diff --git a/src/renderer/plugins/outliner/components/widget/ol-topic-block-content.tsx b/src/renderer/plugins/outliner/components/widget/ol-topic-block-content.tsx index ba9526e..65f2a3e 100644 --- a/src/renderer/plugins/outliner/components/widget/ol-topic-block-content.tsx +++ b/src/renderer/plugins/outliner/components/widget/ol-topic-block-content.tsx @@ -50,7 +50,9 @@ export function OLTopicBlockContent_(props: Props) { const sel = window.getSelection(); let hasText = false; const callback = () => () => { + // console.log('callback', { hasText }); if (hasText) { + console.log('hasText'); document.execCommand('paste'); navigator.clipboard.writeText(''); } @@ -98,6 +100,7 @@ export function OLTopicBlockContent_(props: Props) { range.setEndAfter(innerEditorDiv.lastChild); sel.removeAllRanges(); sel.addRange(range); + hasText = !!sel.toString(); console.log('cut'); document.execCommand('cut'); console.log('operation'); @@ -170,7 +173,7 @@ export function OLTopicBlockContent_(props: Props) { handleOnInput, innerEditorDivRef, className: 'bm-content-editable-ol', - readOnly: model.focusKey !== topicKey + // readOnly: model.focusKey !== topicKey })} </OLTopicBlockContentRoot> ); diff --git a/tsconfig.main.dev.json b/tsconfig.main.dev.json index 9ffb536..7a8c0ad 100644 --- a/tsconfig.main.dev.json +++ b/tsconfig.main.dev.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "target": "es5", "module": "commonjs", "resolveJsonModule": true, "esModuleInterop": true, diff --git a/tsconfig.main.prod.json b/tsconfig.main.prod.json index d2ca035..32d89ee 100644 --- a/tsconfig.main.prod.json +++ b/tsconfig.main.prod.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "target": "es5", "module": "commonjs", "resolveJsonModule": true, "esModuleInterop": true,