From 9991690a7e75876c8a00707926b20583332b2286 Mon Sep 17 00:00:00 2001 From: Kiran Niranjan Date: Fri, 9 Mar 2018 16:16:47 +0530 Subject: [PATCH] Electron-266 (Custom title bar for Windows 10) (#311) 1. Implemented new title bar style for Windows 2. Updated design 3. Updated design and added some safety checks 4. Added title bar double click event 5. Added API to update DOM element 6. Updated style as per the design spec 7. Refactored code and handled full screen scenario 8. Added borders to window 9. Added z-index to make sure border is always on top 10. Updated logic to check Windows 10 version 11. Setting frame property as true for pop-out windows 12. Optimized the code to initiate Windows title bar only after injecting CSS 13. Removed API to update content height 14. Added a global config field to enable or disable custom title bar 15. Reading `isCustomTitleBar` before creating main window 16. Fixed shortcut suggestion in menu items 17. Added two more missing shortcut suggestion in menu items 18. Updated menu template to dynamically assign accelerators 19. Changed the func name closeButtonClick to closeWindow 20. Converted all HEX color code to rgba --- config/Symphony.config | 1 + js/enums/api.js | 3 +- js/mainApiMgr.js | 6 + js/menus/menuTemplate.js | 91 ++++++++------ js/preload/preloadMain.js | 8 ++ js/utils/misc.js | 9 +- js/windowMgr.js | 56 ++++++--- js/windowsTitleBar/contents.js | 121 +++++++++++++++++++ js/windowsTitleBar/index.js | 209 +++++++++++++++++++++++++++++++++ js/windowsTitleBar/style.css | 98 ++++++++++++++++ 10 files changed, 551 insertions(+), 51 deletions(-) create mode 100644 js/windowsTitleBar/contents.js create mode 100644 js/windowsTitleBar/index.js create mode 100644 js/windowsTitleBar/style.css diff --git a/config/Symphony.config b/config/Symphony.config index ae59b873..c77eb2ab 100644 --- a/config/Symphony.config +++ b/config/Symphony.config @@ -5,6 +5,7 @@ "alwaysOnTop" : false, "bringToFront": false, "whitelistUrl": "*", + "isCustomTitleBar": true, "notificationSettings": { "position": "upper-right", "display": "" diff --git a/js/enums/api.js b/js/enums/api.js index 16fda17d..e7abdc8a 100644 --- a/js/enums/api.js +++ b/js/enums/api.js @@ -18,7 +18,8 @@ const cmds = keyMirror({ showNotificationSettings: null, sanitize: null, bringToFront: null, - openScreenPickerWindow: null + openScreenPickerWindow: null, + popupMenu: null }); module.exports = { diff --git a/js/mainApiMgr.js b/js/mainApiMgr.js index f7349a9c..ba5518c7 100644 --- a/js/mainApiMgr.js +++ b/js/mainApiMgr.js @@ -141,6 +141,12 @@ electron.ipcMain.on(apiName, (event, arg) => { openScreenPickerWindow(event.sender, arg.sources, arg.id); } break; + case apiCmds.popupMenu: + var browserWin = electron.BrowserWindow.fromWebContents(event.sender); + if (browserWin && !browserWin.isDestroyed()) { + windowMgr.getMenu().popup(browserWin, { x: 20, y: 15, async: true }); + } + break; default: } diff --git a/js/menus/menuTemplate.js b/js/menus/menuTemplate.js index 5afcec22..8752e443 100644 --- a/js/menus/menuTemplate.js +++ b/js/menus/menuTemplate.js @@ -3,7 +3,7 @@ const electron = require('electron'); const { updateConfigField, getMultipleConfigField } = require('../config.js'); const AutoLaunch = require('auto-launch'); -const isMac = require('../utils/misc.js').isMac; +const { isMac, isWindowsOS } = require('../utils/misc.js'); const log = require('../log.js'); const logLevels = require('../enums/logLevels.js'); const eventEmitter = require('../eventEmitter'); @@ -24,6 +24,22 @@ let bringToFront = false; let symphonyAutoLauncher; +const windowsAccelerator = Object.assign({ + undo: 'Ctrl+Z', + redo: 'Ctrl+Y', + cut: 'Ctrl+X', + copy: 'Ctrl+C', + paste: 'Ctrl+V', + pasteandmatchstyle: 'Ctrl+Shift+V', + selectall: 'Ctrl+A', + resetzoom: 'Ctrl+0', + zoomin: 'Ctrl+Shift+Plus', + zoomout: 'Ctrl+-', + togglefullscreen: 'F11', + minimize: 'Ctrl+M', + close: 'Ctrl+W', +}); + if (isMac) { symphonyAutoLauncher = new AutoLaunch({ name: 'Symphony', @@ -42,15 +58,15 @@ if (isMac) { const template = [{ label: 'Edit', submenu: [ - { role: 'undo' }, - { role: 'redo' }, - { type: 'separator' }, - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - { role: 'pasteandmatchstyle' }, - { role: 'delete' }, - { role: 'selectall' } + buildMenuItem('undo'), + buildMenuItem('redo'), + { type: 'separator' }, + buildMenuItem('cut'), + buildMenuItem('copy'), + buildMenuItem('paste'), + buildMenuItem('pasteandmatchstyle'), + buildMenuItem('delete'), + buildMenuItem('selectall') ] }, { @@ -96,34 +112,19 @@ const template = [{ electron.shell.showItemInFolder(crashesDirectory); } }, - { - type: 'separator' - }, - { - role: 'resetzoom' - }, - { - role: 'zoomin' - }, - { - role: 'zoomout' - }, - { - type: 'separator' - }, - { - role: 'togglefullscreen' - } + { type: 'separator' }, + buildMenuItem('resetzoom'), + buildMenuItem('zoomin'), + buildMenuItem('zoomout'), + { type: 'separator' }, + buildMenuItem('togglefullscreen'), ] }, { role: 'window', - submenu: [{ - role: 'minimize' - }, - { - role: 'close' - } + submenu: [ + buildMenuItem('minimize'), + buildMenuItem('close'), ] }, { @@ -341,6 +342,28 @@ function setCheckboxValues() { }); } +/** + * Sets respective accelerators w.r.t roles for the menu template + * + * @param role {String} The action of the menu item + * + * @return {Object} + * @return {Object}.role The action of the menu item + * @return {Object}.accelerator keyboard shortcuts and modifiers + */ +function buildMenuItem(role) { + + if (isMac) { + return { role: role } + } + + if (isWindowsOS) { + return { role: role, accelerator: windowsAccelerator[role] || '' } + } + + return { role: role } +} + function getMinimizeOnClose() { return minimizeOnClose; } diff --git a/js/preload/preloadMain.js b/js/preload/preloadMain.js index 8beba256..e97a7dc6 100644 --- a/js/preload/preloadMain.js +++ b/js/preload/preloadMain.js @@ -19,6 +19,8 @@ const apiCmds = apiEnums.cmds; const apiName = apiEnums.apiName; const getMediaSources = require('../desktopCapturer/getSources'); const getMediaSource = require('../desktopCapturer/getSource'); +const { TitleBar, updateContentHeight } = require('../windowsTitlebar'); +const titleBar = new TitleBar(); require('../downloadManager'); @@ -387,6 +389,12 @@ function createAPI() { } }); + // Adds custom title bar style for Windows 10 OS + local.ipcRenderer.on('initiate-windows-title-bar', () => { + titleBar.initiateWindowsTitleBar(); + updateContentHeight(); + }); + function updateOnlineStatus() { local.ipcRenderer.send(apiName, { cmd: apiCmds.isOnline, diff --git a/js/utils/misc.js b/js/utils/misc.js index 6ad3cf52..bd2262ba 100644 --- a/js/utils/misc.js +++ b/js/utils/misc.js @@ -1,4 +1,5 @@ 'use strict'; +const os = require('os'); const isDevEnv = process.env.ELECTRON_DEV ? process.env.ELECTRON_DEV.trim().toLowerCase() === 'true' : false; @@ -8,9 +9,15 @@ const isWindowsOS = (process.platform === 'win32'); const isNodeEnv = !!process.env.NODE_ENV; +function isWindows10() { + const [ major ] = os.release().split('.').map((part) => parseInt(part, 10)); + return isWindowsOS && major >= 10; +} + module.exports = { isDevEnv: isDevEnv, isMac: isMac, isWindowsOS: isWindowsOS, - isNodeEnv: isNodeEnv + isNodeEnv: isNodeEnv, + isWindows10: isWindows10 }; diff --git a/js/windowMgr.js b/js/windowMgr.js index 536bcb22..7fa0d68b 100644 --- a/js/windowMgr.js +++ b/js/windowMgr.js @@ -19,8 +19,8 @@ const logLevels = require('./enums/logLevels.js'); const notify = require('./notify/electron-notify.js'); const eventEmitter = require('./eventEmitter'); const throttle = require('./utils/throttle.js'); -const { getConfigField, updateConfigField } = require('./config.js'); -const { isMac, isNodeEnv } = require('./utils/misc'); +const { getConfigField, updateConfigField, getGlobalConfigField } = require('./config.js'); +const { isMac, isNodeEnv, isWindows10 } = require('./utils/misc'); const { deleteIndexFolder } = require('./search/search.js'); const { isWhitelisted } = require('./utils/whitelistHandler'); @@ -44,6 +44,9 @@ let sandboxed = false; let defaultDownloadsDirectory = app.getPath("downloads"); let downloadsDirectory = defaultDownloadsDirectory; +// Application menu +let menu; + // note: this file is built using browserify in prebuild step. const preloadMainScript = path.join(__dirname, 'preload/_preloadMain.js'); @@ -85,25 +88,28 @@ function getParsedUrl(url) { * @param initialUrl */ function createMainWindow(initialUrl) { - getConfigField('mainWinPos').then( - function (bounds) { - doCreateMainWindow(initialUrl, bounds); - }, - function () { - // failed, use default bounds - doCreateMainWindow(initialUrl, null); - } - ); + Promise.all([ + getConfigField('mainWinPos'), + getGlobalConfigField('isCustomTitleBar') + ]).then((values) => { + doCreateMainWindow(initialUrl, values[ 0 ], values[ 1 ]); + }).catch(() => { + // failed use default bounds and frame + doCreateMainWindow(initialUrl, null, false); + }); } /** * Creates the main window with bounds * @param initialUrl * @param initialBounds + * @param isCustomTitleBar {Boolean} - Global config value weather to enable custom title bar */ -function doCreateMainWindow(initialUrl, initialBounds) { +function doCreateMainWindow(initialUrl, initialBounds, isCustomTitleBar) { let url = initialUrl; let key = getGuid(); + // condition whether to enable custom Windows 10 title bar + const isCustomTitleBarEnabled = typeof isCustomTitleBar === 'boolean' && isCustomTitleBar && isWindows10(); log.send(logLevels.INFO, 'creating main window url: ' + url); @@ -112,6 +118,7 @@ function doCreateMainWindow(initialUrl, initialBounds) { show: true, minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT, + frame: !isCustomTitleBarEnabled, alwaysOnTop: false, webPreferences: { sandbox: sandboxed, @@ -174,6 +181,11 @@ function doCreateMainWindow(initialUrl, initialBounds) { // we might not have network connectivity, so warn the user. mainWindow.webContents.on('did-finish-load', function () { url = mainWindow.webContents.getURL(); + if (isCustomTitleBarEnabled) { + mainWindow.webContents.insertCSS(fs.readFileSync(path.join(__dirname, '/windowsTitleBar/style.css'), 'utf8').toString()); + // This is required to initiate Windows title bar only after insertCSS + mainWindow.webContents.send('initiate-windows-title-bar'); + } if (!isOnline) { loadErrors.showNetworkConnectivityError(mainWindow, url, retry); @@ -215,8 +227,12 @@ function doCreateMainWindow(initialUrl, initialBounds) { addWindowKey(key, mainWindow); mainWindow.loadURL(url); - const menu = electron.Menu.buildFromTemplate(getTemplate(app)); - electron.Menu.setApplicationMenu(menu); + menu = electron.Menu.buildFromTemplate(getTemplate(app)); + if (isWindows10()) { + mainWindow.setMenu(menu); + } else { + electron.Menu.setApplicationMenu(menu); + } mainWindow.on('close', function (e) { if (willQuitApp) { @@ -371,6 +387,7 @@ function doCreateMainWindow(initialUrl, initialBounds) { newWinOptions.minWidth = MIN_WIDTH; newWinOptions.minHeight = MIN_HEIGHT; newWinOptions.alwaysOnTop = alwaysOnTop; + newWinOptions.frame = true; let newWinKey = getGuid(); @@ -516,6 +533,14 @@ function getMainWindow() { return mainWindow; } +/** + * Gets the application menu + * @returns {*} + */ +function getMenu() { + return menu; +} + /** * Gets a window's size and position * @param window @@ -839,5 +864,6 @@ module.exports = { setIsOnline: setIsOnline, activate: activate, setBoundsChangeWindow: setBoundsChangeWindow, - verifyDisplays: verifyDisplays + verifyDisplays: verifyDisplays, + getMenu: getMenu }; diff --git a/js/windowsTitleBar/contents.js b/js/windowsTitleBar/contents.js new file mode 100644 index 00000000..d8ef9748 --- /dev/null +++ b/js/windowsTitleBar/contents.js @@ -0,0 +1,121 @@ +const titleBar = (` +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+`); + +const button = (` +
+
+ +
+
+ +
+
+ +
+
+`); + +const unMaximizeButton = (` + + + +`); + +const maximizeButton = (` + + + +`); + + +module.exports = { + titleBar: titleBar, + button: button, + unMaximizeButton: unMaximizeButton, + maximizeButton: maximizeButton +}; \ No newline at end of file diff --git a/js/windowsTitleBar/index.js b/js/windowsTitleBar/index.js new file mode 100644 index 00000000..fcb7d5cf --- /dev/null +++ b/js/windowsTitleBar/index.js @@ -0,0 +1,209 @@ +const { ipcRenderer, remote } = require('electron'); +const apiEnums = require('../enums/api.js'); +const apiCmds = apiEnums.cmds; +const apiName = apiEnums.apiName; +const htmlContents = require('./contents'); + +// Default title bar height +const titleBarHeight = '32px'; + +class TitleBar { + + constructor() { + this.window = remote.getCurrentWindow(); + this.domParser = new DOMParser(); + + const titleBarParsed = this.domParser.parseFromString(htmlContents.titleBar, 'text/html'); + this.titleBar = titleBarParsed.getElementById('title-bar'); + } + + initiateWindowsTitleBar() { + + const actionItemsParsed = this.domParser.parseFromString(htmlContents.button, 'text/html'); + const buttons = actionItemsParsed.getElementsByClassName('action-items'); + + let items = Array.from(buttons[0].children); + for (let i of items) { + this.titleBar.appendChild(i); + } + + const updateIcon = TitleBar.updateIcons; + const updateTitleBar = TitleBar.updateTitleBar; + + // Event to capture and update icons + this.window.on('maximize', updateIcon.bind(this, true)); + this.window.on('unmaximize', updateIcon.bind(this, false)); + this.window.on('enter-full-screen', updateTitleBar.bind(this, true)); + this.window.on('leave-full-screen', updateTitleBar.bind(this, false)); + + window.addEventListener('beforeunload', () => { + this.window.removeListener('maximize', updateIcon); + this.window.removeListener('unmaximize', updateIcon); + this.window.removeListener('enter-full-screen', updateTitleBar); + this.window.removeListener('leave-full-screen', updateTitleBar); + }); + + document.body.appendChild(this.titleBar); + + TitleBar.addWindowBorders(); + this.initiateEventListeners(); + } + + /** + * Method that attaches Event Listeners for elements + */ + initiateEventListeners() { + const hamburgerMenuButton = document.getElementById('hamburger-menu-button'); + const minimizeButton = document.getElementById('title-bar-minimize-button'); + const maximizeButton = document.getElementById('title-bar-maximize-button'); + const closeButton = document.getElementById('title-bar-close-button'); + + attachEventListeners(this.titleBar, 'dblclick', this.maximizeOrUnmaximize.bind(this)); + attachEventListeners(hamburgerMenuButton, 'click', this.popupMenu.bind(this)); + attachEventListeners(closeButton, 'click', this.closeWindow.bind(this)); + attachEventListeners(maximizeButton, 'click', this.maximizeOrUnmaximize.bind(this)); + attachEventListeners(minimizeButton, 'click', this.minimize.bind(this)); + } + + /** + * Method that adds borders + */ + static addWindowBorders() { + const borderBottom = document.createElement('div'); + borderBottom.className = 'bottom-window-border'; + + document.body.appendChild(borderBottom); + document.body.classList.add('window-border'); + } + + /** + * Method that updates the state of the maximize or + * unmaximize icons + * @param isMaximized + */ + static updateIcons(isMaximized) { + const button = document.getElementById('title-bar-maximize-button'); + + if (!button) { + return + } + + if (isMaximized) { + button.innerHTML = htmlContents.unMaximizeButton; + } else { + button.innerHTML = htmlContents.maximizeButton; + } + } + + /** + * Method that updates the title bar display property + * based on the full screen event + * @param isFullScreen {Boolean} + */ + static updateTitleBar(isFullScreen) { + if (isFullScreen) { + this.titleBar.style.display = 'none'; + updateContentHeight('0px'); + } else { + this.titleBar.style.display = 'flex'; + updateContentHeight(); + } + + } + + /** + * Method that popup the application menu + */ + popupMenu() { + if (this.isValidWindow()) { + ipcRenderer.send(apiName, { + cmd: apiCmds.popupMenu + }); + } + } + + /** + * Method that minimizes browser window + */ + minimize() { + if (this.isValidWindow()) { + this.window.minimize(); + } + } + + /** + * Method that maximize or unmaximize browser window + */ + maximizeOrUnmaximize() { + + if (!this.isValidWindow()) { + return; + } + + if (this.window.isMaximized()) { + this.window.unmaximize(); + } else { + this.window.maximize(); + } + } + + /** + * Method that closes the browser window + */ + closeWindow() { + if (this.isValidWindow()) { + this.window.close(); + } + } + + /** + * Verifies if the window exists and is not destroyed + * @returns {boolean} + */ + isValidWindow() { + return !!(this.window && !this.window.isDestroyed()); + } +} + +/** + * Will attach event listeners for a given element + * @param element + * @param eventName + * @param func + */ +function attachEventListeners(element, eventName, func) { + + if (!element || !eventName) { + return; + } + + eventName.split(" ").forEach((name) => { + element.addEventListener(name, func, false); + }); +} + +/** + * Method that adds margin property to the push + * the client content below the title bar + * @param height + */ +function updateContentHeight(height = titleBarHeight) { + const contentWrapper = document.getElementById('content-wrapper'); + const titleBar = document.getElementById('title-bar'); + + if (!titleBar) { + return; + } + + if (contentWrapper) { + contentWrapper.style.marginTop = titleBar ? height : '0px'; + document.body.style.removeProperty('margin-top'); + } else { + document.body.style.marginTop = titleBar ? height : '0px' + } +} + +module.exports = { + TitleBar, + updateContentHeight +}; \ No newline at end of file diff --git a/js/windowsTitleBar/style.css b/js/windowsTitleBar/style.css new file mode 100644 index 00000000..29221288 --- /dev/null +++ b/js/windowsTitleBar/style.css @@ -0,0 +1,98 @@ +#title-bar { + display: flex; + position: fixed; + background: rgba(74,74,74,1); + top: 0; + left: 0; + width: 100%; + height: 32px; + padding-left: 0; + justify-content: center; + align-items: center; + -webkit-app-region: drag; + -webkit-user-select: none; + box-sizing: content-box; + z-index: 1000; +} + +#hamburger-menu-button { + color: rgba(255,255,255,1); + text-align: center; + width: 40px; + height: 32px; + background: none; + border: none; + border-image: initial; + display: inline-grid; + border-radius: 0; + padding: 11px; + box-sizing: border-box; + cursor: default; +} + +#hamburger-menu-button:focus { + outline: none; +} + +#title-container { + height: 32px; + flex: 1; + justify-content: center; + align-items: center; + white-space: nowrap; + overflow: hidden; +} + +.title-bar-button-container { + justify-content: center; + align-items: center; + right: 0; + color: rgba(255,255,255,1); + -webkit-app-region: no-drag; + text-align: center; + width: 45px; + height: 32px; + background: rgba(74,74,74,1); + margin: 0; + box-sizing: border-box !important; + cursor: default; +} + +.title-bar-button { + color: rgba(255,255,255,1); + text-align: center; + width: 45px; + height: 32px; + background: none; + border: none; + border-image: initial; + display: inline-grid; + border-radius: 0; + padding: 10px 15px; + cursor: default; +} + +.title-bar-button:hover { + background-color: rgba(51,51,51,1); +} + +.title-bar-button:focus { + outline: none; +} + +.title-bar-button-container:hover { + background-color: rgba(51,51,51,1); +} + +.window-border { + border-left: 1px solid rgba(74,74,74,1); + border-right: 1px solid rgba(74,74,74,1); +} + +.bottom-window-border { + position: fixed; + border-bottom: 1px solid rgba(74,74,74,1); + width: 100%; + z-index: 3000; + bottom: 0; +} \ No newline at end of file