├── docs ├── _Footer.md ├── Titlebar-Methods.md ├── _Sidebar.md ├── CSS-Customization.md ├── Home.md ├── Colors.md ├── Get-Started.md ├── Titlebar-Options.md ├── Menubar-Options.md └── Menu-Icons.md ├── static └── theme │ ├── mac.css │ ├── win.css │ └── base.css ├── .github ├── FUNDING.yml ├── workflows │ ├── publish-wiki.yaml │ ├── build.yml │ └── deploy.yml └── ISSUE_TEMPLATE │ └── issue-report.md ├── example ├── styles.css ├── assets │ ├── run.png │ ├── home.png │ ├── terminal.png │ ├── icons.json │ └── logo.svg ├── renderer.js ├── index.html ├── preload.js └── main.js ├── .prettierrc ├── screenshots ├── 262shots_so.jpg ├── 544shots_so.jpg ├── 70shots_so.jpg └── 780shots_so.jpg ├── __mocks__ ├── base │ └── common │ │ ├── color.js │ │ └── platform.js └── electron.js ├── .vscode ├── extensions.json ├── tasks.json └── launch.json ├── jest.config.js ├── revert.sh ├── .babelrc ├── .releaserc.yml ├── src ├── main │ ├── index.ts │ ├── attach-titlebar-to-window.ts │ └── setup-titlebar.ts ├── index.ts ├── base │ ├── common │ │ ├── arrays.ts │ │ ├── lifecycle.ts │ │ ├── strings.ts │ │ ├── async.ts │ │ ├── linkedList.ts │ │ ├── decorators.ts │ │ ├── platform.ts │ │ ├── iterator.ts │ │ └── color.ts │ └── browser │ │ ├── event.ts │ │ ├── browser.ts │ │ ├── mouseEvent.ts │ │ ├── keyboardEvent.ts │ │ └── touch.ts ├── menubar │ ├── menu │ │ ├── separator.ts │ │ ├── submenu.ts │ │ ├── item.ts │ │ └── index.ts │ └── menubar-options.ts ├── titlebar │ ├── themebar.ts │ └── options.ts └── consts.ts ├── .npmignore ├── tsconfig.json ├── .eslintrc ├── LICENSE ├── CHANGELOG.md ├── __test__ └── titlebar.test.js ├── package.json ├── README.md └── .gitignore /docs/_Footer.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/theme/mac.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/theme/win.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/Titlebar-Methods.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: AlexTorresDev 2 | -------------------------------------------------------------------------------- /example/styles.css: -------------------------------------------------------------------------------- 1 | /* styles.css */ 2 | 3 | /* Add styles here to customize the appearance of your app */ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } -------------------------------------------------------------------------------- /example/assets/run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexTorresDev/custom-electron-titlebar/HEAD/example/assets/run.png -------------------------------------------------------------------------------- /example/assets/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexTorresDev/custom-electron-titlebar/HEAD/example/assets/home.png -------------------------------------------------------------------------------- /example/assets/terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexTorresDev/custom-electron-titlebar/HEAD/example/assets/terminal.png -------------------------------------------------------------------------------- /screenshots/262shots_so.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexTorresDev/custom-electron-titlebar/HEAD/screenshots/262shots_so.jpg -------------------------------------------------------------------------------- /screenshots/544shots_so.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexTorresDev/custom-electron-titlebar/HEAD/screenshots/544shots_so.jpg -------------------------------------------------------------------------------- /screenshots/70shots_so.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexTorresDev/custom-electron-titlebar/HEAD/screenshots/70shots_so.jpg -------------------------------------------------------------------------------- /screenshots/780shots_so.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexTorresDev/custom-electron-titlebar/HEAD/screenshots/780shots_so.jpg -------------------------------------------------------------------------------- /__mocks__/base/common/color.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Color: { 3 | fromHex: jest.fn(), 4 | MOCKED_COLOR: '#000000' 5 | } 6 | } -------------------------------------------------------------------------------- /__mocks__/base/common/platform.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | platform: 'Windows', 3 | isMacintosh: false, 4 | isWindows: true, 5 | isLinux: false 6 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "usernamehw.errorlens", 4 | "dbaeumer.vscode-eslint", 5 | "pflannery.vscode-versionlens", 6 | ] 7 | } -------------------------------------------------------------------------------- /__mocks__/electron.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ipcRenderer: { 3 | invoke: jest.fn(), 4 | on: jest.fn(), 5 | send: jest.fn(), 6 | sendSync: jest.fn() 7 | }, 8 | Menu: { 9 | buildFromTemplate: jest.fn() 10 | } 11 | } -------------------------------------------------------------------------------- /docs/_Sidebar.md: -------------------------------------------------------------------------------- 1 | - [Get Started](./Get-Started) 2 | - [Titlebar Options](./Titlebar-Options) 3 | - [Menubar Options](./Menubar-Options) 4 | - [Colors](./Colors) 5 | - [Menu Icons](./Menu-Icons) 6 | - [Titlebar Methods](./Titlebar-Methods) 7 | - [CSS Customization](./CSS-Customization) 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest/presets/js-with-babel', 3 | testEnvironment: 'jsdom', 4 | moduleNameMapper: { 5 | '^base/(.*)$': '/src/base/$1', 6 | '^static/(.*)$': '/static/$1' 7 | }, 8 | globals: { 9 | 'ts-jest': { 10 | babelConfig: true 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /revert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Reverts ./dist path changes created in pipeline by package publish to NPM 4 | sed -i -e '/"types":/,/},/{s|"\./|"./dist/|g}' -e '/"exports":/,/},/{/\"\.\/main\":/! s|"\./|"./dist/|g; s|"\./main/index.js|"./dist/main/index.js|g}' ../package.json 5 | 6 | # Deletes postversion script in package.json of NPM package 7 | sed -i '/"postversion":/d' package.json -------------------------------------------------------------------------------- /example/renderer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is loaded via the 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/base/common/arrays.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | /** 7 | * Returns the last element of an array. 8 | * @param array The array. 9 | * @param n Which element from the end (default is zero). 10 | */ 11 | export function tail(array: ArrayLike, n: number = 0): T { 12 | return array[array.length - (1 + n)]; 13 | } 14 | 15 | /** 16 | * @returns a new array with all falsy values removed. The original array IS NOT modified. 17 | */ 18 | export function coalesce(array: Array): T[] { 19 | if (!array) { 20 | return array 21 | } 22 | return array.filter(e => !!e) 23 | } -------------------------------------------------------------------------------- /docs/Home.md: -------------------------------------------------------------------------------- 1 | ## Welcome to Custom Electron Titlebar wiki! 2 | 3 | ### What is Custom Electron Titlebar? 4 | 5 | Custom Electron Titlebar is a library that allows you to create a fully customized title bar for your Electron application. 6 | 7 | ### Why use Custom Electron Titlebar? 8 | 9 | Using the Custom Electron Titlebar in your Electron project offers a powerful solution for achieving a distinct and cohesive user interface. By seamlessly integrating a tailor-made title bar, you can enhance the overall aesthetics and branding of your application. This library allows developers to exercise creative control over every aspect of the title bar's appearance and behavior, ensuring a seamless user experience that aligns perfectly with your application's unique design and functionality. 10 | 11 | ### Features 12 | 13 | - Fast integration 14 | - Customizable 15 | - Updated to the latest version of Electron 16 | - Support for Windows, Linux and MacOS 17 | - Customizable application menu -------------------------------------------------------------------------------- /example/preload.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The preload script runs before. It has access to web APIs 3 | * as well as Electron's renderer process modules and some 4 | * polyfilled Node.js functions. 5 | * 6 | * https://www.electronjs.org/docs/latest/tutorial/sandbox 7 | */ 8 | const { CustomTitlebar, TitlebarColor } = require('custom-electron-titlebar') 9 | const path = require('path') 10 | 11 | window.addEventListener('DOMContentLoaded', () => { 12 | const replaceText = (selector, text) => { 13 | const element = document.getElementById(selector) 14 | if (element) element.innerText = text 15 | } 16 | 17 | for (const type of ['chrome', 'node', 'electron']) { 18 | replaceText(`${type}-version`, process.versions[type]) 19 | } 20 | 21 | // eslint-disable-next-line no-new 22 | new CustomTitlebar({ 23 | backgroundColor: TitlebarColor.fromHex('#6538b9'), 24 | menuTransparency: 0.2 25 | // icon: path.resolve('example/assets', 'logo.svg'), 26 | // icons: path.resolve('example/assets', 'icons.json'), 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /docs/Colors.md: -------------------------------------------------------------------------------- 1 | There is a class called `TitlebarColor` that allows you to configure the colors of the title bar. 2 | 3 | ## Predefined Colors 4 | 5 | The `TitlebarColor` class has several predefined colors that can be used to configure the colors of the title bar. 6 | 7 | - TitlebarColor.BLACK 8 | - TitlebarColor.BLUE 9 | - TitlebarColor.CYAN 10 | - TitlebarColor.GREEN 11 | - TitlebarColor.LIGHTGREY 12 | - TitlebarColor.RED 13 | - TitlebarColor.WHITE 14 | - TitlebarColor.TRANSPARENT 15 | 16 | ## Creating a Custom Color 17 | 18 | To create a custom color, use the static method `TitlebarColor.fromHex(color)`. This method takes a hexadecimal color as an argument and returns a `TitlebarColor` object. 19 | 20 | ```js 21 | const customColor = TitlebarColor.fromHex('#FF0000') 22 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alex Torres 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | types: [opened, synchronize] 11 | 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: write 16 | id-token: write 17 | 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.ref }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | build: 24 | name: 📦 Build 25 | runs-on: ubuntu-22.04 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v3 29 | - name: Install pnpm 30 | uses: pnpm/action-setup@v2 31 | with: 32 | version: 7 33 | run_install: false 34 | - name: Install Node.js 35 | uses: actions/setup-node@v3 36 | with: 37 | node-version: "lts/*" 38 | cache: pnpm 39 | - run: pnpm install --no-frozen-lockfile 40 | - name: Build 41 | run: pnpm build 42 | - name: Test 43 | run: pnpm test 44 | - name: Upload artifact 45 | uses: actions/upload-artifact@v3 46 | with: 47 | name: build-artifact 48 | path: dist 49 | -------------------------------------------------------------------------------- /src/main/attach-titlebar-to-window.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) AlexTorresDev. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | import { WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH } from 'consts' 6 | import { BrowserWindow } from 'electron' 7 | 8 | export default (browserWindow: BrowserWindow) => { 9 | browserWindow.setMinimumSize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT) 10 | 11 | browserWindow.on('enter-full-screen', () => { 12 | browserWindow.webContents.send('window-fullscreen', true) 13 | }) 14 | 15 | browserWindow.on('leave-full-screen', () => { 16 | browserWindow.webContents.send('window-fullscreen', false) 17 | }) 18 | 19 | browserWindow.on('focus', () => { 20 | browserWindow.webContents.send('window-focus', true) 21 | }) 22 | 23 | browserWindow.on('blur', () => { 24 | browserWindow.webContents.send('window-focus', false) 25 | }) 26 | 27 | browserWindow.on('maximize', () => { 28 | browserWindow.webContents.send('window-maximize', true) 29 | }) 30 | 31 | browserWindow.on('unmaximize', () => { 32 | browserWindow.webContents.send('window-maximize', false) 33 | }) 34 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Electron Main", 6 | "request": "launch", 7 | "type": "node", 8 | "presentation": { 9 | "hidden": true, 10 | }, 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 12 | "preLaunchTask": "npm: build", 13 | "args": [ 14 | "--remote-debugging-port=9223", 15 | "./example/main.js" 16 | ], 17 | "outFiles": [ 18 | "${workspaceFolder}/example/**/*.js" 19 | ] 20 | }, 21 | { 22 | "name": "Electron Renderer", 23 | "type": "chrome", 24 | "request": "attach", 25 | "presentation": { 26 | "hidden": true, 27 | }, 28 | "port": 9223, 29 | "urlFilter": "*index.html", 30 | "timeout": 30000, 31 | "skipFiles": [ 32 | "${workspaceFolder}/node_modules/**/*.js", 33 | "${workspaceFolder}/dist/**/*.js", 34 | "/**/*.js" 35 | ], 36 | "trace": true, 37 | "smartStep": true, 38 | "showAsyncStacks": true, 39 | "webRoot": "${workspaceFolder}", 40 | } 41 | ], 42 | "compounds": [ 43 | { 44 | "name": "Debug CET", 45 | "configurations": [ 46 | "Electron Main", 47 | "Electron Renderer" 48 | ] 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /src/menubar/menu/separator.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) AlexTorresDev. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { MenuItem } from 'electron' 7 | import { CETMenuItem, IMenuStyle } from './item' 8 | import { IMenuOptions } from './index' 9 | import { $, append } from 'base/common/dom' 10 | import { MenuBarOptions } from 'menubar/menubar-options' 11 | import { IMenuIcons } from 'menubar' 12 | 13 | export class CETSeparator extends CETMenuItem { 14 | private separatorElement?: HTMLElement 15 | 16 | constructor(item: MenuItem, submenuIcons: IMenuIcons, submenuParentOptions: MenuBarOptions, submenuOptions: IMenuOptions) { 17 | super(item, submenuIcons, submenuParentOptions, submenuOptions) 18 | } 19 | 20 | render(container: HTMLElement) { 21 | if (container) { 22 | this.separatorElement = append(container, $('a.cet-action-label.separator', { role: 'presentation' })) 23 | } 24 | } 25 | 26 | updateStyle(style: IMenuStyle) { 27 | if (this.separatorElement && style.separatorColor) { 28 | this.separatorElement.style.borderBottomColor = style.separatorColor.toString() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/Get-Started.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | How to install this library in your Electron project? 4 | 5 | ```sh 6 | npm i custom-electron-titlebar 7 | ``` 8 | 9 | ## How to use? 10 | 11 | In the main file of the project `main.js` or `index.js` import the library and call the `setupTitlebar` and `attachTitlebarToWindow` functions: 12 | 13 | ```js 14 | const { setupTitlebar, attachTitlebarToWindow } = require('custom-electron-titlebar/main'); 15 | 16 | // setup the titlebar main process 17 | setupTitlebar(); 18 | 19 | function createWindow() { 20 | // Create the browser window. 21 | const mainWindow = new BrowserWindow({ 22 | width: 800, 23 | height: 600, 24 | //frame: false, // needed if process.versions.electron < 14 25 | titleBarStyle: 'hidden', 26 | /* You can use *titleBarOverlay: true* to use the original Windows controls */ 27 | titleBarOverlay: true, 28 | webPreferences: { 29 | sandbox: false, 30 | preload: path.join(__dirname, 'preload.js') 31 | } 32 | }); 33 | 34 | ... 35 | 36 | // attach fullscreen(f11 and not 'maximized') && focus listeners 37 | attachTitlebarToWindow(mainWindow); 38 | } 39 | ``` 40 | 41 | It is important that the `titleBarStyle` property is `hidden` so that the default Electron title bar is not displayed. 42 | Likewise, the sandbox property must be added to false so that the library can function correctly. -------------------------------------------------------------------------------- /example/assets/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "check": "", 3 | "arrow": "", 4 | "windows": { 5 | "minimize": "", 6 | "maximize": "", 7 | "restore": "", 8 | "close": "" 9 | } 10 | } -------------------------------------------------------------------------------- /src/base/browser/event.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { Event, Emitter } from 'base/common/event' 7 | 8 | export type EventHandler = HTMLElement | HTMLDocument | Window; 9 | 10 | export interface IDomEvent { 11 | // eslint-disable-next-line no-undef 12 | (element: EventHandler, type: K, useCapture?: boolean): Event; 13 | (element: EventHandler, type: string, useCapture?: boolean): Event; 14 | } 15 | 16 | export const domEvent: IDomEvent = (element: EventHandler, type: string, useCapture?: boolean) => { 17 | const fn = (e: any) => emitter.fire(e) 18 | const emitter = new Emitter({ 19 | onFirstListenerAdd: () => { 20 | element.addEventListener(type, fn, useCapture) 21 | }, 22 | onLastListenerRemove: () => { 23 | element.removeEventListener(type, fn, useCapture) 24 | } 25 | }) 26 | 27 | return emitter.event 28 | } 29 | 30 | export interface CancellableEvent { 31 | preventDefault(): any; 32 | stopPropagation(): any; 33 | } 34 | 35 | export function stop(event: Event): Event { 36 | return Event.map(event, e => { 37 | e.preventDefault() 38 | e.stopPropagation() 39 | return e 40 | }) 41 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [4.2.8](https://github.com/AlexTorresDev/custom-electron-titlebar/compare/v4.2.7...v4.2.8) (2024-01-15) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * **unfocusEffect:** :bug: Fix unfocusEffect on titlebar ([45c6917](https://github.com/AlexTorresDev/custom-electron-titlebar/commit/45c6917c41743ba51fd6f44c3102f85600d66392)) 9 | 10 | ## [4.2.7](https://github.com/AlexTorresDev/custom-electron-titlebar/compare/v4.2.6...v4.2.7) (2023-08-13) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * **window-controls:** :adhesive_bandage: Fix Maximization button ([38740fc](https://github.com/AlexTorresDev/custom-electron-titlebar/commit/38740fcd9450b9b02654005f562a13c81bbc180e)), closes [#235](https://github.com/AlexTorresDev/custom-electron-titlebar/issues/235) 16 | 17 | ## [4.2.6](https://github.com/AlexTorresDev/custom-electron-titlebar/compare/v4.2.5...v4.2.6) (2023-08-10) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * **bgColor:** :bug: Fixed a background color ([d29b38a](https://github.com/AlexTorresDev/custom-electron-titlebar/commit/d29b38a6d7841d51a916fde917b0e8e6d6de8928)) 23 | * **bgColor:** :bug: Fixed a background color ([776a8bb](https://github.com/AlexTorresDev/custom-electron-titlebar/commit/776a8bbd3e8eb8e371b600d26260caf38bdf92dd)) 24 | 25 | ## [4.2.5](https://github.com/AlexTorresDev/custom-electron-titlebar/compare/v4.2.4...v4.2.5) (2023-07-24) 26 | 27 | 28 | ### Bug Fixes 29 | 30 | * **titlebar:** :adhesive_bandage: Fixed bugs detected by the test ([1a6bbab](https://github.com/AlexTorresDev/custom-electron-titlebar/commit/1a6bbab23da624bcb5040eeff49ce7b8d8a0e0cb)) 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Problem Description 11 | 12 | [Describe the problem here clearly and concisely. Provide details about what is happening and how it affects the project's functionality.] 13 | 14 | ## Steps to Reproduce 15 | 16 | 1. [List the specific steps to reproduce the issue.] 17 | 2. [If possible, provide links, screenshots, or relevant code snippets.] 18 | 19 | ## Expected Behavior 20 | 21 | [Explain what the expected behavior of the project should be in this situation.] 22 | 23 | ## Current Behavior 24 | 25 | [Describe how the project is currently behaving instead of the expected behavior. Include any error messages, warnings, or other relevant details.] 26 | 27 | ## Additional Information 28 | 29 | [Provide any other relevant information, such as your operating system, version of the project you are using, versions of dependencies, etc.] 30 | 31 | ## Tentative Solution Steps 32 | 33 | [If you have any ideas on how to fix this issue, share them here. If not, don't worry, the project team can help find a solution.] 34 | 35 | ## Screenshots 36 | 37 | [If relevant, you can attach screenshots that demonstrate the problem.] 38 | 39 | ## Additional Context 40 | 41 | [Add any other context that might be useful for understanding the issue, such as recent code changes, events that might have triggered the problem, etc.] 42 | 43 | > **Note:** 44 | Labels - Select the appropriate labels that best describe this issue, such as "bug", "enhancement", "support", etc. 45 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: write 8 | id-token: write 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | release: 16 | name: 🚀 Deploy 17 | if: github.ref == 'refs/heads/main' 18 | runs-on: ubuntu-22.04 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 0 24 | persist-credentials: false 25 | - name: Download artifact 26 | uses: dawidd6/action-download-artifact@v2 27 | with: 28 | workflow: build.yml 29 | github_token: ${{ secrets.PAT }} 30 | name: build-artifact 31 | path: dist 32 | - name: Install Node.js 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: "lts/*" 36 | - name: Set correct path 37 | run: sed -i "s/\/dist//g" package.json 38 | - name: Copy files 39 | run: | 40 | cp README.md dist 41 | cp LICENSE dist 42 | cp package.json dist 43 | cp .npmignore dist 44 | - name: Semantic Release 45 | uses: cycjimmy/semantic-release-action@v3 46 | with: 47 | extra_plugins: | 48 | @semantic-release/changelog 49 | @semantic-release/git 50 | env: 51 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 52 | GITHUB_TOKEN: ${{ secrets.PAT }} 53 | GIT_AUTHOR_NAME: 'GitHub Actions' 54 | GIT_AUTHOR_EMAIL: 'action@github.com' 55 | GIT_COMMITTER_NAME: 'GitHub Actions' 56 | GIT_COMMITTER_EMAIL: 'action@github.com' 57 | -------------------------------------------------------------------------------- /src/menubar/menubar-options.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) AlexTorresDev. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { Menu } from 'electron' 7 | import { Color } from 'base/common/color' 8 | 9 | export interface MenuBarOptions { 10 | /** 11 | * Enable the mnemonics on menubar and menu items 12 | * **The default is true** 13 | */ 14 | enableMnemonics?: boolean; 15 | /** 16 | * The path of the icons of menubar. 17 | */ 18 | icons?: string; 19 | /** 20 | * The background color when the mouse is over the item. 21 | * **The default is undefined** 22 | */ 23 | itemBackgroundColor?: Color; 24 | /** 25 | * The background color of the menu. 26 | * **The default is automatic** 27 | */ 28 | menuBarBackgroundColor?: Color; 29 | /** 30 | * The position of menubar on titlebar. 31 | * **The default is left** 32 | */ 33 | menuPosition?: 'left' | 'bottom'; 34 | /** 35 | * The color of the menu separator. 36 | * **The default is automatic** 37 | */ 38 | menuSeparatorColor?: Color; 39 | /** 40 | * The menu container transparency 41 | * **The default is 0 (not apply transparency)* 42 | */ 43 | menuTransparency?: number; 44 | /** 45 | * Define if is only rendering the menubar without the titlebar. 46 | * **The default is false** 47 | */ 48 | onlyShowMenuBar?: boolean; 49 | /** 50 | * Define if MenuBar exists on TitleBar or not. 51 | * **The default is false** 52 | */ 53 | removeMenuBar?: boolean; 54 | /** 55 | * The color of the svg icons in the menu 56 | * **The default is automatic** 57 | */ 58 | svgColor?: Color; 59 | } -------------------------------------------------------------------------------- /__test__/titlebar.test.js: -------------------------------------------------------------------------------- 1 | const { CustomTitlebar } = require('../dist/titlebar/index') 2 | const { BOTTOM_TITLEBAR_HEIGHT } = require('../dist/consts') 3 | 4 | jest.mock('electron') 5 | jest.mock('base/common/color') 6 | jest.mock('base/common/platform') 7 | 8 | describe('CustomTitlebar', () => { 9 | let titlebar 10 | 11 | beforeEach(() => { 12 | jest.clearAllMocks() 13 | titlebar = new CustomTitlebar() 14 | }) 15 | 16 | afterEach(() => { 17 | titlebar?.dispose() 18 | }) 19 | 20 | test('should create an instance of CustomTitlebar', () => { 21 | expect(titlebar).toBeInstanceOf(CustomTitlebar) 22 | }) 23 | 24 | test('should update the title', () => { 25 | titlebar.updateTitle('Test Title') 26 | expect(titlebar.titleElement.innerText).toBe('Test Title') 27 | expect(document.title).toBe('Test Title') 28 | }) 29 | 30 | test('should update the menu position', () => { 31 | titlebar.updateMenuPosition('bottom') 32 | expect(titlebar.titlebarElement.style.height).toBe(`${BOTTOM_TITLEBAR_HEIGHT}px`) 33 | expect(titlebar.containerElement.style.top).toBe(`${BOTTOM_TITLEBAR_HEIGHT}px`) 34 | expect(titlebar.menuBarContainer.classList.contains('bottom')).toBe(true) 35 | }) 36 | 37 | /* test('should update the background color', () => { 38 | const { Color } = require('base/common/color') 39 | titlebar.updateBackground(Color.fromHex('#ffffff')) 40 | console.log(titlebar.titlebarElement.style) 41 | expect(titlebar.titlebarElement.style.backgroundColor).toBe('#ffffff') 42 | }) 43 | 44 | test('should background color from string', async () => { 45 | await titlebar.updateBackground('#ffffff') 46 | console.log(titlebar.titlebarElement.style) 47 | expect(titlebar.titlebarElement.style.backgroundColor).toBe('#ffffff') 48 | }) */ 49 | 50 | test('should refresh the menu', async () => { 51 | const { ipcRenderer } = require('electron') 52 | const mockedMenu = { items: [{ label: 'Test &Menu' }] } 53 | 54 | ipcRenderer.invoke.mockResolvedValue(mockedMenu) 55 | 56 | await titlebar.refreshMenu() 57 | 58 | expect(ipcRenderer.invoke).toHaveBeenCalledWith('request-application-menu') 59 | expect(titlebar.menuBarContainer.children.length).toBe(2) // Because the 'More' button is added 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/base/common/lifecycle.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | export interface IDisposable { 7 | dispose(): void; 8 | } 9 | 10 | export function isDisposable(thing: E): thing is E & IDisposable { 11 | return typeof (thing).dispose === 'function' && 12 | (thing).dispose.length === 0 13 | } 14 | 15 | export function dispose(disposable: T): T; 16 | export function dispose(...disposables: Array): T[]; 17 | export function dispose(disposables: T[]): T[]; 18 | export function dispose(first: T | T[], ...rest: T[]): T | T[] | undefined { 19 | if (Array.isArray(first)) { 20 | first.forEach(d => d && d.dispose()) 21 | return [] 22 | } else if (rest.length === 0) { 23 | if (first) { 24 | first.dispose() 25 | return first 26 | } 27 | return undefined 28 | } else { 29 | dispose(first) 30 | dispose(rest) 31 | return [] 32 | } 33 | } 34 | 35 | export function combinedDisposable(disposables: IDisposable[]): IDisposable { 36 | return { dispose: () => dispose(disposables) } 37 | } 38 | 39 | export function toDisposable(fn: () => void): IDisposable { 40 | return { dispose() { fn() } } 41 | } 42 | 43 | export abstract class Disposable implements IDisposable { 44 | static None = Object.freeze({ dispose() { } }) 45 | 46 | protected _toDispose: IDisposable[] = [] 47 | protected get toDispose(): IDisposable[] { return this._toDispose } 48 | 49 | private _lifecycle_disposable_isDisposed = false 50 | 51 | public dispose(): void { 52 | this._lifecycle_disposable_isDisposed = true 53 | this._toDispose = dispose(this._toDispose) 54 | } 55 | 56 | protected _register(t: T): T { 57 | if (this._lifecycle_disposable_isDisposed) { 58 | console.warn('Registering disposable on object that has already been disposed.') 59 | t.dispose() 60 | } else { 61 | this._toDispose.push(t) 62 | } 63 | 64 | return t 65 | } 66 | } -------------------------------------------------------------------------------- /example/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-electron-titlebar", 3 | "version": "4.2.8", 4 | "description": "Library for electron that allows you to configure a fully customizable title bar.", 5 | "types": "./dist/index.d.ts", 6 | "main": "./dist/index.ts", 7 | "typesVersions": { 8 | "*": { 9 | "main": [ 10 | "./dist/main/index.d.ts" 11 | ] 12 | } 13 | }, 14 | "exports": { 15 | ".": "./dist/index.js", 16 | "./main": "./dist/main/index.js" 17 | }, 18 | "scripts": { 19 | "clean": "rimraf dist", 20 | "build:package": "tsc && tsc-alias", 21 | "build:babel": "babel dist --out-dir dist --extensions \".js\"", 22 | "start": "electron example/main.js", 23 | "dev": "npm run build && npm run start", 24 | "build": "npm run clean && npm run build:package && npm run build:babel", 25 | "postversion": "cp package.json .. && chmod u+x ../revert.sh && ../revert.sh", 26 | "test": "jest" 27 | }, 28 | "author": "AlexTorresDev ", 29 | "license": "MIT", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/AlexTorresDev/custom-electron-titlebar.git" 33 | }, 34 | "keywords": [ 35 | "typescript", 36 | "electron", 37 | "title bar", 38 | "menubar", 39 | "windows", 40 | "linux", 41 | "freebsd" 42 | ], 43 | "bugs": { 44 | "url": "https://github.com/AlexTorresDev/custom-electron-titlebar/issues" 45 | }, 46 | "homepage": "https://github.com/AlexTorresDev/custom-electron-titlebar#readme", 47 | "peerDependencies": { 48 | "electron": ">20.0.0" 49 | }, 50 | "devDependencies": { 51 | "@babel/cli": "^7.23.4", 52 | "@babel/core": "^7.23.7", 53 | "@jest/globals": "^29.7.0", 54 | "@typescript-eslint/eslint-plugin": "^6.18.1", 55 | "@typescript-eslint/parser": "^6.18.1", 56 | "babel-plugin-import-require-as-string": "^1.0.2", 57 | "babel-plugin-module-resolver": "^5.0.0", 58 | "babel-plugin-rewire": "^1.2.0", 59 | "electron": "^28.1.3", 60 | "eslint": "^8.56.0", 61 | "eslint-config-prettier": "^9.1.0", 62 | "eslint-config-standard": "^17.1.0", 63 | "eslint-plugin-import": "^2.29.1", 64 | "eslint-plugin-n": "^16.6.2", 65 | "eslint-plugin-promise": "^6.1.1", 66 | "jest": "^29.7.0", 67 | "jest-environment-jsdom": "^29.7.0", 68 | "rimraf": "^5.0.5", 69 | "standard": "^17.1.0", 70 | "ts-jest": "^29.1.1", 71 | "tsc-alias": "^1.8.8", 72 | "typescript": "^5.3.3" 73 | }, 74 | "eslintConfig": { 75 | "extends": "./node_modules/standard/eslintrc.json" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/base/common/strings.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | /** 7 | * Converts HTML characters inside the string to use entities instead. Makes the string safe from 8 | * being used e.g. in HTMLElement.innerHTML. 9 | */ 10 | export function escape(html: string): string { 11 | return html.replace(/[<>&]/g, function (match) { 12 | switch (match) { 13 | case '<': return '<'; 14 | case '>': return '>'; 15 | case '&': return '&'; 16 | default: return match; 17 | } 18 | }); 19 | } 20 | 21 | /** 22 | * Removes all occurrences of needle from the beginning and end of haystack. 23 | * @param haystack string to trim 24 | * @param needle the thing to trim (default is a blank) 25 | */ 26 | export function trim(haystack: string, needle: string = ' '): string { 27 | const trimmed = ltrim(haystack, needle); 28 | return rtrim(trimmed, needle); 29 | } 30 | 31 | /** 32 | * Removes all occurrences of needle from the beginning of haystack. 33 | * @param haystack string to trim 34 | * @param needle the thing to trim 35 | */ 36 | export function ltrim(haystack: string, needle: string): string { 37 | if (!haystack || !needle) { 38 | return haystack; 39 | } 40 | 41 | const needleLen = needle.length; 42 | if (needleLen === 0 || haystack.length === 0) { 43 | return haystack; 44 | } 45 | 46 | let offset = 0; 47 | 48 | while (haystack.indexOf(needle, offset) === offset) { 49 | offset = offset + needleLen; 50 | } 51 | return haystack.substring(offset); 52 | } 53 | 54 | /** 55 | * Removes all occurrences of needle from the end of haystack. 56 | * @param haystack string to trim 57 | * @param needle the thing to trim 58 | */ 59 | export function rtrim(haystack: string, needle: string): string { 60 | if (!haystack || !needle) { 61 | return haystack; 62 | } 63 | 64 | const needleLen = needle.length, 65 | haystackLen = haystack.length; 66 | 67 | if (needleLen === 0 || haystackLen === 0) { 68 | return haystack; 69 | } 70 | 71 | let offset = haystackLen, 72 | idx = -1; 73 | 74 | while (true) { 75 | idx = haystack.lastIndexOf(needle, offset - 1); 76 | if (idx === -1 || idx + needleLen !== offset) { 77 | break; 78 | } 79 | if (idx === 0) { 80 | return ''; 81 | } 82 | offset = idx; 83 | } 84 | 85 | return haystack.substring(0, offset); 86 | } 87 | -------------------------------------------------------------------------------- /src/titlebar/themebar.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { toDisposable, IDisposable, Disposable } from 'base/common/lifecycle' 7 | const baseTheme: string = require('static/theme/base.css') 8 | const macTheme: string = require('static/theme/mac.css') 9 | const winTheme: string = require('static/theme/win.css') 10 | 11 | export interface CssStyle { 12 | addRule(rule: string): void; 13 | } 14 | 15 | export interface Theme { 16 | (collector: CssStyle): void; 17 | } 18 | 19 | class ThemingRegistry extends Disposable { 20 | private readonly theming: Theme[] = [] 21 | 22 | constructor() { 23 | super() 24 | 25 | this.theming = [] 26 | } 27 | 28 | protected onThemeChange(theme: Theme): IDisposable { 29 | this.theming.push(theme) 30 | return toDisposable(() => { 31 | const idx = this.theming.indexOf(theme) 32 | this.theming.splice(idx, 1) 33 | }) 34 | } 35 | 36 | protected getTheming(): Theme[] { 37 | return this.theming 38 | } 39 | } 40 | 41 | export class ThemeBar extends ThemingRegistry { 42 | constructor() { 43 | super() 44 | 45 | this.registerTheme((collector: CssStyle) => { 46 | collector.addRule(baseTheme) 47 | }) 48 | } 49 | 50 | protected registerTheme(theme: Theme) { 51 | this.onThemeChange(theme) 52 | 53 | const cssRules: string[] = [] 54 | const hasRule: { [rule: string]: boolean } = {} 55 | const ruleCollector = { 56 | addRule: (rule: string) => { 57 | if (!hasRule[rule]) { 58 | cssRules.push(rule) 59 | hasRule[rule] = true 60 | } 61 | } 62 | } 63 | 64 | this.getTheming().forEach(p => p(ruleCollector)) 65 | 66 | _applyRules(cssRules.join('\n'), 'titlebar-style') 67 | } 68 | 69 | static get win(): Theme { 70 | return (collector: CssStyle) => { 71 | collector.addRule(winTheme) 72 | } 73 | } 74 | 75 | static get mac(): Theme { 76 | return (collector: CssStyle) => { 77 | collector.addRule(macTheme) 78 | } 79 | } 80 | } 81 | 82 | function _applyRules(styleSheetContent: string, rulesClassName: string) { 83 | const themeStyles = document.head.getElementsByClassName(rulesClassName) 84 | 85 | if (themeStyles.length === 0) { 86 | const styleElement = document.createElement('style') 87 | styleElement.className = rulesClassName 88 | styleElement.innerHTML = styleSheetContent 89 | document.head.appendChild(styleElement) 90 | } else { 91 | (themeStyles[0]).innerHTML = styleSheetContent 92 | } 93 | } -------------------------------------------------------------------------------- /src/base/common/async.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { Disposable } from 'base/common/lifecycle' 7 | 8 | export class TimeoutTimer extends Disposable { 9 | private _token: any 10 | 11 | constructor(); 12 | constructor(runner: () => void, timeout: number); 13 | constructor(runner?: () => void, timeout?: number) { 14 | super() 15 | this._token = -1 16 | 17 | if (typeof runner === 'function' && typeof timeout === 'number') { 18 | this.setIfNotSet(runner, timeout) 19 | } 20 | } 21 | 22 | dispose(): void { 23 | this.cancel() 24 | super.dispose() 25 | } 26 | 27 | cancel(): void { 28 | if (this._token !== -1) { 29 | clearTimeout(this._token) 30 | this._token = -1 31 | } 32 | } 33 | 34 | cancelAndSet(runner: () => void, timeout: number): void { 35 | this.cancel() 36 | this._token = setTimeout(() => { 37 | this._token = -1 38 | runner() 39 | }, timeout) 40 | } 41 | 42 | setIfNotSet(runner: () => void, timeout: number): void { 43 | if (this._token !== -1) { 44 | // timer is already set 45 | return 46 | } 47 | this._token = setTimeout(() => { 48 | this._token = -1 49 | runner() 50 | }, timeout) 51 | } 52 | } 53 | 54 | export class RunOnceScheduler { 55 | protected runner: ((...args: any[]) => void) | null 56 | 57 | private timeoutToken: any 58 | private timeout: number 59 | private timeoutHandler: () => void 60 | 61 | constructor(runner: (...args: any[]) => void, timeout: number) { 62 | this.timeoutToken = -1 63 | this.runner = runner 64 | this.timeout = timeout 65 | this.timeoutHandler = this.onTimeout.bind(this) 66 | } 67 | 68 | /** 69 | * Dispose RunOnceScheduler 70 | */ 71 | dispose(): void { 72 | this.cancel() 73 | this.runner = null 74 | } 75 | 76 | /** 77 | * Cancel current scheduled runner (if any). 78 | */ 79 | cancel(): void { 80 | if (this.isScheduled()) { 81 | clearTimeout(this.timeoutToken) 82 | this.timeoutToken = -1 83 | } 84 | } 85 | 86 | /** 87 | * Cancel previous runner (if any) & schedule a new runner. 88 | */ 89 | schedule(delay = this.timeout): void { 90 | this.cancel() 91 | this.timeoutToken = setTimeout(this.timeoutHandler, delay) 92 | } 93 | 94 | /** 95 | * Returns true if scheduled. 96 | */ 97 | isScheduled(): boolean { 98 | return this.timeoutToken !== -1 99 | } 100 | 101 | private onTimeout() { 102 | this.timeoutToken = -1 103 | if (this.runner) { 104 | this.doRun() 105 | } 106 | } 107 | 108 | protected doRun(): void { 109 | if (this.runner) { 110 | this.runner() 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /src/main/setup-titlebar.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) AlexTorresDev. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | export default () => { 7 | if (process.type !== 'browser') return 8 | 9 | const { BrowserWindow, Menu, MenuItem, ipcMain } = require('electron') 10 | 11 | // Send menu to renderer title bar process 12 | ipcMain.handle('request-application-menu', async () => JSON.parse(JSON.stringify( 13 | Menu.getApplicationMenu(), 14 | (key: string, value: any) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined) 15 | )) 16 | 17 | // Handle window events 18 | ipcMain.on('window-event', (event, eventName: String) => { 19 | const window = BrowserWindow.fromWebContents(event.sender) 20 | 21 | /* eslint-disable indent */ 22 | if (window) { 23 | switch (eventName) { 24 | case 'window-minimize': 25 | window?.minimize() 26 | break 27 | case 'window-maximize': 28 | window?.isMaximized() ? window.unmaximize() : window?.maximize() 29 | break 30 | case 'window-close': 31 | window?.close() 32 | break 33 | case 'window-is-maximized': 34 | event.returnValue = window?.isMaximized() 35 | break 36 | default: 37 | break 38 | } 39 | } 40 | }) 41 | 42 | // Handle menu events 43 | ipcMain.on('menu-event', (event, commandId: Number) => { 44 | const item = getMenuItemByCommandId(commandId, Menu.getApplicationMenu()) 45 | if (item) item.click(undefined, BrowserWindow.fromWebContents(event.sender), event.sender) 46 | }) 47 | 48 | // Handle the minimum size. 49 | ipcMain.on('window-set-minimumSize', (event, width, height) => { 50 | const window = BrowserWindow.fromWebContents(event.sender); 51 | 52 | /* eslint-disable indent */ 53 | if (window) { 54 | window?.setMinimumSize(width, height); 55 | } 56 | }); 57 | 58 | // Handle menu item icon 59 | ipcMain.on('menu-icon', (event, commandId: Number) => { 60 | const item = getMenuItemByCommandId(commandId, Menu.getApplicationMenu()) 61 | if (item && item.icon && typeof item.icon !== 'string') { 62 | event.returnValue = item.icon.toDataURL() 63 | } else { 64 | event.returnValue = null 65 | } 66 | }) 67 | 68 | ipcMain.on('update-window-controls', (event, args: Electron.TitleBarOverlay) => { 69 | const window = BrowserWindow.fromWebContents(event.sender) 70 | try { 71 | if (window) window.setTitleBarOverlay(args) 72 | event.returnValue = true 73 | } catch (_) { 74 | event.returnValue = false 75 | } 76 | }) 77 | } 78 | 79 | function getMenuItemByCommandId(commandId: Number, menu: Electron.Menu | null): Electron.MenuItem | undefined { 80 | if (!menu) return undefined 81 | 82 | for (const item of menu.items) { 83 | if (item.submenu) { 84 | const submenuItem = getMenuItemByCommandId(commandId, item.submenu) 85 | if (submenuItem) return submenuItem 86 | } else if (item.commandId === commandId) return item 87 | } 88 | 89 | return undefined 90 | } 91 | -------------------------------------------------------------------------------- /docs/Titlebar-Options.md: -------------------------------------------------------------------------------- 1 | > If you want to learn about the menu bar options, see [Menubar Options](./menubar-options). 2 | 3 | The titlebar has various options that allow for customization. These options are passed as an object to the `Titlebar` or `CustomTitlebar` component: 4 | 5 | ```js 6 | const options = { 7 | // options 8 | }; 9 | 10 | new Titlebar(options); 11 | ``` 12 | 13 | ## Background color of the titlebar 14 | This is the background color of the titlebar. It can be a hexadecimal color using `TitlebarColor.fromHex(color)` or a `TitlebarColor`. 15 | 16 | For more details on colors, see [Colors](./Colors). 17 | 18 | ```js 19 | const options = { 20 | backgroundColor: TitlebarColor.fromHex('#FF0000') 21 | }; 22 | ``` 23 | 24 | ## Container overflow 25 | 26 | The overflow of the container is the way the content is displayed when the container size is smaller than the content size. It can be `auto`, `hidden` or `visible`. 27 | 28 | ```js 29 | const options = { 30 | overflow: 'auto' 31 | }; 32 | ``` 33 | 34 | ## Application icon 35 | 36 | This is the icon that is displayed in the titlebar. It can be a `NativeImage` icon or a path to an image file. 37 | 38 | ```js 39 | const options = { 40 | icon: path.join(__dirname, 'icon.png') 41 | }; 42 | ``` 43 | 44 | or using `nativeImage` 45 | ```js 46 | const options = { 47 | icon: nativeImage.createFromPath(path.join(__dirname, 'icon.png')) 48 | }; 49 | ``` 50 | 51 | For more details on `NativeImage`, see [Electron NativeImage](https://www.electronjs.org/docs/latest/api/native-image). 52 | 53 | ## Application icon size 54 | 55 | This is the size of the icon that is displayed in the titlebar. This must be a number and must be between 16 and 24. (size in pixels) 56 | 57 | ```js 58 | const options = { 59 | iconSize: 20 60 | }; 61 | ``` 62 | 63 | ## Title location 64 | 65 | This is the location of the title. It can be `left`, `center` or `right`. 66 | 67 | ```js 68 | const options = { 69 | titleHorizontalAlignment: 'left' 70 | }; 71 | ``` 72 | 73 | ## Buttons order 74 | 75 | It can be `inverted` or `first-buttons`. 76 | 77 | `inverted` completely reverses the bar, meaning buttons on the left are shown on the right and vice versa. 78 | 79 | `first-buttons` shows the titlebar normally, but buttons on the right are shown on the left. 80 | 81 | ```js 82 | const options = { 83 | order: 'inverted' 84 | }; 85 | ``` 86 | 87 | ## Titlebar buttons 88 | 89 | ### Minimize 90 | 91 | Indicates whether the minimize button is enabled or not. 92 | 93 | ```js 94 | const options = { 95 | minimizable: true 96 | } 97 | ``` 98 | 99 | ### Maximize 100 | 101 | Indicates whether the maximize button is enabled or not. 102 | 103 | ```js 104 | const options = { 105 | maximizable: true 106 | } 107 | ``` 108 | 109 | ### Close 110 | 111 | Indicates whether the close button is enabled or not. 112 | 113 | ```js 114 | const options = { 115 | closeable: true 116 | } 117 | ``` 118 | 119 | ## Button tooltips 120 | 121 | Allows for customization of the button titles that are displayed when hovering over them. 122 | 123 | ```js 124 | const options = { 125 | tooltips: { 126 | minimize: 'Minimize', 127 | maximize: 'Maximize', 128 | restoreDown: 'Restore', 129 | close: 'Close' 130 | } 131 | } 132 | ``` -------------------------------------------------------------------------------- /src/titlebar/options.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) AlexTorresDev. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { NativeImage } from 'electron' 7 | import { Color } from 'base/common/color' 8 | import { MenuBarOptions } from '../menubar/menubar-options' 9 | 10 | export interface TitleBarOptions extends MenuBarOptions { 11 | /** 12 | * The background color of titlebar. 13 | * **The default is `#ffffff`** 14 | */ 15 | backgroundColor?: Color 16 | /** 17 | * Sets the value for the overflow of the container after title bar. 18 | * **The default value is auto** 19 | */ 20 | containerOverflow?: 'auto' | 'hidden' | 'visible' 21 | /** 22 | * Define if the close button is enabled. 23 | * **The default is true** 24 | */ 25 | closeable?: boolean 26 | /** 27 | * When the close button is clicked, the window is hidden instead of closed. 28 | * **The default is false** 29 | */ 30 | // hideWhenClickingClose?: boolean; 31 | /** 32 | * The icon shown on the left side of titlebar. 33 | * **The default is the favicon of the index.html** 34 | */ 35 | icon?: NativeImage | string 36 | /** 37 | * The icon size of titlebar. Value between 16 and 24. 38 | * **The default is 16** 39 | */ 40 | iconSize?: number 41 | /** 42 | * Define if the maximize and restore buttons are enabled. 43 | * **The default is true** 44 | */ 45 | maximizable?: boolean 46 | /** 47 | * Define if the minimize button is enabled. 48 | * **The default is true** 49 | */ 50 | minimizable?: boolean 51 | /** 52 | * Set the order of the elements on the title bar. You can use `inverted`, `first-buttons` or don't add for. 53 | * **The default is undefined** 54 | */ 55 | order?: 'inverted' | 'first-buttons' 56 | /** 57 | * Show shadow of titlebar. 58 | * **The default is false* 59 | */ 60 | shadow?: boolean 61 | /** 62 | * Set horizontal alignment of the window title. 63 | * **The default value is center** 64 | */ 65 | titleHorizontalAlignment?: 'left' | 'center' | 'right' 66 | /** 67 | * Set the titles of controls of the window. 68 | */ 69 | tooltips?: { 70 | /** 71 | * The tooltip of minimize button. 72 | * **The default is "Minimize"** 73 | */ 74 | minimize?: string 75 | /** 76 | * The tooltip of maximize button. 77 | * **The default is "Maximize"** 78 | */ 79 | maximize?: string 80 | /** 81 | * The tooltip of restore button. 82 | * **The default is "Restore Down"** 83 | */ 84 | restoreDown?: string 85 | /** 86 | * The tooltip of close button. 87 | * **The default is "Close"** 88 | */ 89 | close?: string 90 | }, 91 | /** 92 | * Enables or disables the blur option in titlebar. 93 | * *The default is true* 94 | */ 95 | unfocusEffect?: boolean; 96 | /** 97 | * Controls the Minimum Width the user is allowed to resize the window to. 98 | * **The default is 400* 99 | */ 100 | minWidth: 400; 101 | 102 | /** 103 | * Controls the Minimum Height the user is allowed to resize the window to. 104 | * **The default is 270* 105 | */ 106 | minHeight: 270; 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom Electron Titlebar 2 | 3 | This project is a typescript library for electron that allows you to configure a fully customizable title bar. 4 | 5 | [![CI](https://badgen.net/github/checks/AlexTorresDev/custom-electron-titlebar?label=CI)](https://github.com/AlexTorresDev/custom-electron-titlebar/actions/workflows/build-release.yml) 6 | [![License](https://badgen.net/github/license/AlexTorresDev/custom-electron-titlebar?label=License)](https://github.com/AlexTorresDev/custom-electron-titlebar/blob/master/LICENSE) 7 | [![NPM](https://badgen.net/npm/v/custom-electron-titlebar?label=NPM)](https://npmjs.org/package/custom-electron-titlebar) 8 | [![Install size](https://badgen.net/packagephobia/install/custom-electron-titlebar?label=Install%20size)](https://packagephobia.com/result?p=custom-electron-titlebar) 9 | 10 | > [!IMPORTANT] 11 | > This project will no longer be maintained, because I am the only one working on it and I have no free time left to review the issues and incorporate new features or update the dependencies to the latest versions. 12 | > 13 | > **Thanks to all the contributors and dependents of this library.** 14 | 15 | [📄 Documentation](https://github.com/AlexTorresDev/custom-electron-titlebar/wiki) 16 | 17 | ### Standard Title Bar 18 | 19 | ![Screenshot 1](screenshots/70shots_so.jpg) 20 | 21 | ### Bottom Menu Bar 22 | 23 | ![Screenshot 2](screenshots/544shots_so.jpg) 24 | 25 | ### Menu 26 | 27 | ![Screenshot 3](screenshots/780shots_so.jpg) 28 | 29 | ### Custom color 30 | 31 | ![Screenshot 4](screenshots/262shots_so.jpg) 32 | 33 | # 📦 Installing 34 | You can install this package with `npm`, `pnpm` or `yarn`. 35 | ```sh 36 | npm install custom-electron-titlebar 37 | ``` 38 | ```sh 39 | pnpm add custom-electron-titlebar 40 | ``` 41 | ```sh 42 | yarn add custom-electron-titlebar 43 | ``` 44 | 45 | # 🛠️ Usage 46 | The implementation is done as follows: 47 | 48 | In the main application file (main.js or .ts) 49 | ```js 50 | import { setupTitlebar, attachTitlebarToWindow } from "custom-electron-titlebar/main"; 51 | 52 | // setup the titlebar main process 53 | setupTitlebar(); 54 | 55 | function createWindow() { 56 | // Create the browser window. 57 | const mainWindow = new BrowserWindow({ 58 | width: 800, 59 | height: 600, 60 | //frame: false, // needed if process.versions.electron < 14 61 | titleBarStyle: 'hidden', 62 | /* You can use *titleBarOverlay: true* to use the original Windows controls */ 63 | titleBarOverlay: true, 64 | webPreferences: { 65 | sandbox: false, 66 | preload: path.join(__dirname, 'preload.js') 67 | } 68 | }); 69 | 70 | ... 71 | 72 | // attach fullScreen(f11 and not 'maximized') && focus listeners 73 | attachTitlebarToWindow(mainWindow); 74 | } 75 | ``` 76 | 77 | In the preload file (preload.js or .ts) 78 | ```js 79 | import { Titlebar } from "custom-electron-titlebar"; 80 | 81 | window.addEventListener('DOMContentLoaded', () => { 82 | // Title bar implementation 83 | new Titlebar(); 84 | }); 85 | ``` 86 | To see the options you can include in the Title Bar constructor, such as color of elements, icons, menu position, and much more, and the methods you can use, go to the [wiki](https://github.com/AlexTorresDev/custom-electron-titlebar/wiki) 87 | 88 | ## 💰 Support 89 | If you want to support my development, you can do so by donating through [💖 Sponsor](https://github.com/sponsors/AlexTorresDev) 90 | 91 | 92 | ## 📝 Contributors 93 | I would like to express my sincere gratitude to all the people who have collaborated in the development and advancement of this project. I appreciate your contributions. 94 | 95 | [![](https://contrib.rocks/image?repo=AlexTorresDev/custom-electron-titlebar)](https://github.com/AlexTorresDev/custom-electron-titlebar/graphs/contributors) 96 | 97 | 98 | ## ✅ License 99 | This project is under the [MIT](https://github.com/AlexTorresDev/custom-electron-titlebar/blob/master/LICENSE) license. 100 | -------------------------------------------------------------------------------- /docs/Menubar-Options.md: -------------------------------------------------------------------------------- 1 | > If what you want is to know the options for the title bar, see [Titlebar Options](./Titlebar-Options). 2 | 3 | Just like with title bar options, menu options are passed as an object to the `Titlebar` or `CustomTitlebar` component: 4 | 5 | ```js 6 | const options = { 7 | // title bar options 8 | // rest of the menu options 9 | }; 10 | 11 | new Titlebar(options); 12 | ``` 13 | 14 | ## Menu Color 15 | 16 | This is the background color of the menu. It can be a hexadecimal color using `TitlebarColor.fromHex(color)` or a `TitlebarColor`. 17 | 18 | For more color details, see [Colors](./Colors). 19 | 20 | ```js 21 | const options = { 22 | // title bar options 23 | backgroundColor: TitlebarColor.fromHex('#FF0000') 24 | }; 25 | ``` 26 | 27 | ## Enable Mnemonics 28 | 29 | Mnemonics are a way to navigate the user interface using the keyboard. To enable them, you should pass the `enableMnemonics` option as `true`: 30 | 31 | ```js 32 | const options = { 33 | // title bar options 34 | enableMnemonics: true 35 | }; 36 | ``` 37 | 38 | ## Menu Icons 39 | 40 | These are the icons displayed on special menu items, such as **radio**, **checkbox**, and **submenu** items. These are defined in a `JSON` file, and the file path is passed in the options. 41 | 42 | ```js 43 | const options = { 44 | // title bar options 45 | icons: path.join(__dirname, 'menu-icons.json') 46 | }; 47 | ``` 48 | 49 | For more icon details, see [Menu Icons](./Menu-Icons). 50 | 51 | ## Menu 52 | 53 | This is the menu displayed in the menu bar. This option is deprecated, and it's recommended to use `setupTitlebar` in the main application file. 54 | 55 | ```js 56 | const options = { 57 | // title bar options 58 | menu: Menu.buildFromTemplate(template) 59 | }; 60 | ``` 61 | 62 | ## Menu Position 63 | 64 | This is the position of the menu in the title bar. It can be `left` or `bottom`. 65 | 66 | ```js 67 | const options = { 68 | // title bar options 69 | menuPosition: 'left' 70 | }; 71 | ``` 72 | 73 | ## Only Show Menu in Title Bar 74 | 75 | This option allows showing the menu only in the title bar. This removes all elements from the bar except for the buttons. 76 | 77 | ```js 78 | const options = { 79 | // title bar options 80 | onlyShowMenubar: true 81 | }; 82 | ``` 83 | 84 | ## Remove Menu from Title Bar 85 | 86 | This option allows removing the `menubar` from `titlebar`. The default value is `false` 87 | 88 | ```js 89 | const options = { 90 | // title bar options 91 | removeMenuBar: true 92 | }; 93 | ``` 94 | 95 | ## Menu Item Color 96 | 97 | This is the background color of the menu items when the cursor is hovering over each one. It can be a hexadecimal color using `TitlebarColor.fromHex(color)` or a `TitlebarColor`. 98 | 99 | For more color details, see [Colors](./Colors). 100 | 101 | ```js 102 | const options = { 103 | // title bar options 104 | itemBackgroundColor: TitlebarColor.fromHex('#FF0000') 105 | }; 106 | ``` 107 | 108 | ## Menu Item Separator Color 109 | 110 | This is the background color of the menu item separators. It can be a hexadecimal color using `TitlebarColor.fromHex(color)` or a `TitlebarColor`. 111 | 112 | For more color details, see [Colors](./Colors). 113 | 114 | ```js 115 | const options = { 116 | // title bar options 117 | menuSeparatorColor: TitlebarColor.fromHex('#FF0000') 118 | }; 119 | ``` 120 | 121 | ## Menu Icon Color 122 | 123 | This is the color of the menu icons. It can be a hexadecimal color using or a `TitlebarColor`. 124 | 125 | For more color details, see [Colors](./Colors). 126 | 127 | ```js 128 | const options = { 129 | // title bar options 130 | svgColor: TitlebarColor.fromHex('#FF0000') 131 | }; 132 | ``` 133 | 134 | ## Menu Transparency 135 | 136 | This is the transparency of the menu background. It can be a decimal value between `0` and `1`. 137 | 138 | ```js 139 | const options = { 140 | // title bar options 141 | transparent: 0.5 142 | }; 143 | ``` -------------------------------------------------------------------------------- /src/base/common/linkedList.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { Iterator, IteratorResult, FIN } from 'base/common/iterator' 7 | 8 | class Node { 9 | element: E 10 | // eslint-disable-next-line no-use-before-define 11 | next: Node | undefined 12 | // eslint-disable-next-line no-use-before-define 13 | prev: Node | undefined 14 | 15 | constructor(element: E) { 16 | this.element = element 17 | } 18 | } 19 | 20 | export class LinkedList { 21 | private _first: Node | undefined 22 | private _last: Node | undefined 23 | private _size: number = 0 24 | 25 | get size(): number { 26 | return this._size 27 | } 28 | 29 | isEmpty(): boolean { 30 | return !this._first 31 | } 32 | 33 | clear(): void { 34 | this._first = undefined 35 | this._last = undefined 36 | this._size = 0 37 | } 38 | 39 | unshift(element: E): () => void { 40 | return this._insert(element, false) 41 | } 42 | 43 | push(element: E): () => void { 44 | return this._insert(element, true) 45 | } 46 | 47 | private _insert(element: E, atTheEnd: boolean): () => void { 48 | const newNode = new Node(element) 49 | if (!this._first) { 50 | this._first = newNode 51 | this._last = newNode 52 | } else if (atTheEnd) { 53 | // push 54 | const oldLast = this._last! 55 | this._last = newNode 56 | newNode.prev = oldLast 57 | oldLast.next = newNode 58 | } else { 59 | // unshift 60 | const oldFirst = this._first 61 | this._first = newNode 62 | newNode.next = oldFirst 63 | oldFirst.prev = newNode 64 | } 65 | this._size += 1 66 | return this._remove.bind(this, newNode) 67 | } 68 | 69 | 70 | shift(): E | undefined { 71 | if (!this._first) { 72 | return undefined 73 | } else { 74 | const res = this._first.element 75 | this._remove(this._first) 76 | return res 77 | } 78 | } 79 | 80 | pop(): E | undefined { 81 | if (!this._last) { 82 | return undefined 83 | } else { 84 | const res = this._last.element 85 | this._remove(this._last) 86 | return res 87 | } 88 | } 89 | 90 | private _remove(node: Node): void { 91 | let candidate: Node | undefined = this._first 92 | while (candidate instanceof Node) { 93 | if (candidate !== node) { 94 | candidate = candidate.next 95 | continue 96 | } 97 | if (candidate.prev && candidate.next) { 98 | // middle 99 | const anchor = candidate.prev 100 | anchor.next = candidate.next 101 | candidate.next.prev = anchor 102 | } else if (!candidate.prev && !candidate.next) { 103 | // only node 104 | this._first = undefined 105 | this._last = undefined 106 | } else if (!candidate.next) { 107 | // last 108 | this._last = this._last!.prev! 109 | this._last.next = undefined 110 | } else if (!candidate.prev) { 111 | // first 112 | this._first = this._first!.next! 113 | this._first.prev = undefined 114 | } 115 | 116 | // done 117 | this._size -= 1 118 | break 119 | } 120 | } 121 | 122 | iterator(): Iterator { 123 | let element: { done: false; value: E; } 124 | let node = this._first 125 | return { 126 | next(): IteratorResult { 127 | if (!node) { 128 | return FIN 129 | } 130 | 131 | if (!element) { 132 | element = { done: false, value: node.element } 133 | } else { 134 | element.value = node.element 135 | } 136 | node = node.next 137 | return element 138 | } 139 | } 140 | } 141 | 142 | toArray(): E[] { 143 | const result: E[] = [] 144 | for (let node = this._first; node instanceof Node; node = node.next) { 145 | result.push(node.element) 146 | } 147 | return result 148 | } 149 | } -------------------------------------------------------------------------------- /src/base/common/decorators.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | function createDecorator(mapFn: (fn: Function, key: string) => Function): Function { 7 | return (target: any, key: string, descriptor: any) => { 8 | let fnKey: string | null = null; 9 | let fn: Function | null = null; 10 | 11 | if (typeof descriptor.value === 'function') { 12 | fnKey = 'value'; 13 | fn = descriptor.value; 14 | } else if (typeof descriptor.get === 'function') { 15 | fnKey = 'get'; 16 | fn = descriptor.get; 17 | } 18 | 19 | if (!fn) { 20 | throw new Error('not supported'); 21 | } 22 | 23 | descriptor[fnKey!] = mapFn(fn, key); 24 | }; 25 | } 26 | 27 | export function memoize(_target: any, key: string, descriptor: any) { 28 | let fnKey: string | null = null; 29 | let fn: Function | null = null; 30 | 31 | if (typeof descriptor.value === 'function') { 32 | fnKey = 'value'; 33 | fn = descriptor.value; 34 | 35 | if (fn!.length !== 0) { 36 | console.warn('Memoize should only be used in functions with zero parameters'); 37 | } 38 | } else if (typeof descriptor.get === 'function') { 39 | fnKey = 'get'; 40 | fn = descriptor.get; 41 | } 42 | 43 | if (!fn) { 44 | throw new Error('not supported'); 45 | } 46 | 47 | const memoizeKey = `$memoize$${key}`; 48 | descriptor[fnKey!] = function (...args: any[]) { 49 | if (!this.hasOwnProperty(memoizeKey)) { 50 | Object.defineProperty(this, memoizeKey, { 51 | configurable: false, 52 | enumerable: false, 53 | writable: false, 54 | value: fn!.apply(this, args) 55 | }); 56 | } 57 | 58 | return this[memoizeKey]; 59 | }; 60 | } 61 | 62 | export interface IDebounceReducer { 63 | (previousValue: T, ...args: any[]): T; 64 | } 65 | 66 | export function debounce(delay: number, reducer?: IDebounceReducer, initialValueProvider?: () => T): Function { 67 | return createDecorator((fn, key) => { 68 | const timerKey = `$debounce$${key}`; 69 | const resultKey = `$debounce$result$${key}`; 70 | 71 | return function (this: any, ...args: any[]) { 72 | if (!this[resultKey]) { 73 | this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; 74 | } 75 | 76 | clearTimeout(this[timerKey]); 77 | 78 | if (reducer) { 79 | this[resultKey] = reducer(this[resultKey], ...args); 80 | args = [this[resultKey]]; 81 | } 82 | 83 | this[timerKey] = setTimeout(() => { 84 | fn.apply(this, args); 85 | this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; 86 | }, delay); 87 | }; 88 | }); 89 | } 90 | 91 | export function throttle(delay: number, reducer?: IDebounceReducer, initialValueProvider?: () => T): Function { 92 | return createDecorator((fn, key) => { 93 | const timerKey = `$throttle$timer$${key}`; 94 | const resultKey = `$throttle$result$${key}`; 95 | const lastRunKey = `$throttle$lastRun$${key}`; 96 | const pendingKey = `$throttle$pending$${key}`; 97 | 98 | return function (this: any, ...args: any[]) { 99 | if (!this[resultKey]) { 100 | this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; 101 | } 102 | if (this[lastRunKey] === null || this[lastRunKey] === undefined) { 103 | this[lastRunKey] = -Number.MAX_VALUE; 104 | } 105 | 106 | if (reducer) { 107 | this[resultKey] = reducer(this[resultKey], ...args); 108 | } 109 | 110 | if (this[pendingKey]) { 111 | return; 112 | } 113 | 114 | const nextTime = this[lastRunKey] + delay; 115 | if (nextTime <= Date.now()) { 116 | this[lastRunKey] = Date.now(); 117 | fn.apply(this, [this[resultKey]]); 118 | this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; 119 | } else { 120 | this[pendingKey] = true; 121 | this[timerKey] = setTimeout(() => { 122 | this[pendingKey] = false; 123 | this[lastRunKey] = Date.now(); 124 | fn.apply(this, [this[resultKey]]); 125 | this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; 126 | }, nextTime - Date.now()); 127 | } 128 | }; 129 | }); 130 | } -------------------------------------------------------------------------------- /src/base/common/platform.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | /* --------------------------------------------------------------------------------------------- 3 | * Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See License.txt in the project root for license information. 5 | *-------------------------------------------------------------------------------------------- */ 6 | 7 | let _isWindows = false 8 | let _isMacintosh = false 9 | let _isLinux = false 10 | let _isFreeBSD = false 11 | let _isNative = false 12 | let _isWeb = false 13 | 14 | export interface IProcessEnvironment { 15 | [key: string]: string; 16 | } 17 | 18 | interface INodeProcess { 19 | platform: string; 20 | env: IProcessEnvironment; 21 | getuid(): number; 22 | nextTick: Function; 23 | versions?: { 24 | electron?: string; 25 | }; 26 | type?: string; 27 | 28 | } 29 | declare let process: INodeProcess 30 | declare let global: any 31 | 32 | interface INavigator { 33 | userAgent: string; 34 | language: string; 35 | } 36 | declare let navigator: INavigator 37 | declare let self: any 38 | 39 | const isElectronRenderer = (typeof process !== 'undefined' && typeof process.versions !== 'undefined' && typeof process.versions.electron !== 'undefined' && process.type === 'renderer') 40 | 41 | // OS detection 42 | if (typeof navigator === 'object' && !isElectronRenderer) { 43 | const userAgent = navigator.userAgent 44 | _isWindows = userAgent.indexOf('Windows') >= 0 45 | _isMacintosh = userAgent.indexOf('Macintosh') >= 0 46 | _isLinux = userAgent.indexOf('Linux') >= 0 47 | _isFreeBSD = userAgent.indexOf('FreeBSD') >= 0 48 | _isWeb = true 49 | } else if (typeof process === 'object') { 50 | _isWindows = (process.platform === 'win32') 51 | _isMacintosh = (process.platform === 'darwin') 52 | _isLinux = (process.platform === 'linux') 53 | _isFreeBSD = (process.platform === 'freebsd') 54 | _isNative = true 55 | } 56 | 57 | export const enum Platform { 58 | Web, 59 | Mac, 60 | Linux, 61 | FreeBSD, 62 | Windows 63 | } 64 | export function PlatformToString(platform: Platform) { 65 | switch (platform) { 66 | case Platform.Web: return 'Web' 67 | case Platform.Mac: return 'Mac' 68 | case Platform.Linux: return 'Linux' 69 | case Platform.FreeBSD: return 'FreeBSD' 70 | case Platform.Windows: return 'Windows' 71 | } 72 | } 73 | 74 | let _platform: Platform = Platform.Web 75 | if (_isNative) { 76 | if (_isMacintosh) { 77 | _platform = Platform.Mac 78 | } else if (_isWindows) { 79 | _platform = Platform.Windows 80 | } else if (_isLinux) { 81 | _platform = Platform.Linux 82 | } else if (_isFreeBSD) { 83 | _platform = Platform.FreeBSD 84 | } 85 | } 86 | 87 | export const isWindows = _isWindows 88 | export const isMacintosh = _isMacintosh 89 | export const isLinux = _isLinux 90 | export const isFreeBSD = _isFreeBSD 91 | export const isNative = _isNative 92 | export const isWeb = _isWeb 93 | export const platform = _platform 94 | 95 | export function isRootUser(): boolean { 96 | return _isNative && !_isWindows && (process.getuid() === 0) 97 | } 98 | 99 | const g = typeof global === 'object' ? global : {} as any 100 | const _globals = (typeof self === 'object' ? self : g) 101 | export const globals: any = _globals 102 | 103 | let _setImmediate: ((callback: (...args: any[]) => void) => number) | null = null 104 | export function setImmediate(callback: (...args: any[]) => void): number { 105 | if (_setImmediate === null) { 106 | if (globals.setImmediate) { 107 | _setImmediate = globals.setImmediate.bind(globals) 108 | } else if (typeof process !== 'undefined' && typeof process.nextTick === 'function') { 109 | _setImmediate = process.nextTick.bind(process) 110 | } else { 111 | _setImmediate = globals.setTimeout.bind(globals) 112 | } 113 | } 114 | return _setImmediate!(callback) 115 | } 116 | 117 | export const enum OperatingSystem { 118 | Windows = 1, 119 | Macintosh = 2, 120 | Linux = 3, 121 | FreeBSD = 4 122 | } 123 | 124 | const _wl = _isWindows ? OperatingSystem.Windows : OperatingSystem.Linux | OperatingSystem.FreeBSD 125 | export const OS = (_isMacintosh ? OperatingSystem.Macintosh : _wl) 126 | 127 | export const enum AccessibilitySupport { 128 | /** 129 | * This should be the browser case where it is not known if a screen reader is attached or no. 130 | */ 131 | Unknown = 0, 132 | Disabled = 1, 133 | Enabled = 2 134 | } -------------------------------------------------------------------------------- /docs/Menu-Icons.md: -------------------------------------------------------------------------------- 1 | ## File structure 2 | 3 | To customize the menu icons, you need to create a `JSON` file and add the following structure within it: 4 | 5 | ```json 6 | { 7 | "submenuIndicator": "", 8 | "checkbox": "", 9 | "radioChecked": "", 10 | "radioUnchecked": "", 11 | "linux": { 12 | "minimize": "", 13 | "maximize": "", 14 | "restore": "", 15 | "close": "" 16 | }, 17 | "windows": { 18 | "minimize": "", 19 | "maximize": "", 20 | "restore": "", 21 | "close": "" 22 | } 23 | } 24 | ``` 25 | 26 | > These SVGs should have the `fill="currentColor"` attribute so that the color can be adapted correctly to the title bar colors. 27 | 28 | ## Menu items 29 | 30 | - `submenuIndicator` This is the icon for submenus. 31 | 32 | - `checkbox` This is the icon for checkboxes. 33 | 34 | - `radioChecked` This is the icon for radio items when they are selected. 35 | 36 | - `radioUnchecked` This is the icon for radio items when they are not selected. 37 | 38 | ## Titlebar items 39 | 40 | - `minimize` This is the icon for minimizing the window. 41 | 42 | - `maximize` This is the icon for maximizing the window. 43 | 44 | - `restore` This is the icon for restoring the window. 45 | 46 | - `close` This is the icon for closing the window. 47 | 48 | > **Note:**

49 | Title bar icons are not compatible with macOS.
50 | Title bar icons are not displayed when `titlebarOverlay` is set to true. -------------------------------------------------------------------------------- /src/base/browser/browser.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { Emitter, Event } from 'base/common/event' 7 | import { IDisposable } from 'base/common/lifecycle' 8 | 9 | class WindowManager { 10 | public static readonly INSTANCE = new WindowManager() 11 | 12 | private _zoomLevel = 0 13 | private _lastZoomLevelChangeTime = 0 14 | private readonly _onDidChangeZoomLevel = new Emitter() 15 | 16 | public readonly onDidChangeZoomLevel: Event = this._onDidChangeZoomLevel.event 17 | public getZoomLevel(): number { 18 | return this._zoomLevel 19 | } 20 | 21 | public getTimeSinceLastZoomLevelChanged(): number { 22 | return Date.now() - this._lastZoomLevelChangeTime 23 | } 24 | 25 | public setZoomLevel(zoomLevel: number, isTrusted: boolean): void { 26 | if (this._zoomLevel === zoomLevel) { 27 | return 28 | } 29 | 30 | this._zoomLevel = zoomLevel 31 | // See https://github.com/Microsoft/vscode/issues/26151 32 | this._lastZoomLevelChangeTime = isTrusted ? 0 : Date.now() 33 | this._onDidChangeZoomLevel.fire(this._zoomLevel) 34 | } 35 | 36 | // --- Zoom Factor 37 | private _zoomFactor = 0 38 | 39 | public getZoomFactor(): number { 40 | return this._zoomFactor 41 | } 42 | 43 | public setZoomFactor(zoomFactor: number): void { 44 | this._zoomFactor = zoomFactor 45 | } 46 | 47 | // --- Pixel Ratio 48 | public getPixelRatio(): number { 49 | const ctx = document.createElement('canvas').getContext('2d') 50 | const dpr = window.devicePixelRatio || 1 51 | const bsr = (ctx).webkitBackingStorePixelRatio || 52 | (ctx).mozBackingStorePixelRatio || 53 | (ctx).msBackingStorePixelRatio || 54 | (ctx).oBackingStorePixelRatio || 55 | (ctx).backingStorePixelRatio || 1 56 | return dpr / bsr 57 | } 58 | 59 | // --- Fullscreen 60 | private _fullscreen = false 61 | private readonly _onDidChangeFullscreen = new Emitter() 62 | 63 | public readonly onDidChangeFullscreen: Event = this._onDidChangeFullscreen.event 64 | public setFullscreen(fullscreen: boolean): void { 65 | if (this._fullscreen === fullscreen) { 66 | return 67 | } 68 | 69 | this._fullscreen = fullscreen 70 | this._onDidChangeFullscreen.fire() 71 | } 72 | 73 | public isFullscreen(): boolean { 74 | return this._fullscreen 75 | } 76 | } 77 | 78 | /** A zoom index, e.g. 1, 2, 3 */ 79 | export function setZoomLevel(zoomLevel: number, isTrusted: boolean): void { 80 | WindowManager.INSTANCE.setZoomLevel(zoomLevel, isTrusted) 81 | } 82 | export function getZoomLevel(): number { 83 | return WindowManager.INSTANCE.getZoomLevel() 84 | } 85 | /** Returns the time (in ms) since the zoom level was changed */ 86 | export function getTimeSinceLastZoomLevelChanged(): number { 87 | return WindowManager.INSTANCE.getTimeSinceLastZoomLevelChanged() 88 | } 89 | export function onDidChangeZoomLevel(callback: (zoomLevel: number) => void): IDisposable { 90 | return WindowManager.INSTANCE.onDidChangeZoomLevel(callback) 91 | } 92 | 93 | /** The zoom scale for an index, e.g. 1, 1.2, 1.4 */ 94 | export function getZoomFactor(): number { 95 | return WindowManager.INSTANCE.getZoomFactor() 96 | } 97 | export function setZoomFactor(zoomFactor: number): void { 98 | WindowManager.INSTANCE.setZoomFactor(zoomFactor) 99 | } 100 | 101 | export function getPixelRatio(): number { 102 | return WindowManager.INSTANCE.getPixelRatio() 103 | } 104 | 105 | export function setFullscreen(fullscreen: boolean): void { 106 | WindowManager.INSTANCE.setFullscreen(fullscreen) 107 | } 108 | export function isFullscreen(): boolean { 109 | return WindowManager.INSTANCE.isFullscreen() 110 | } 111 | export const onDidChangeFullscreen = WindowManager.INSTANCE.onDidChangeFullscreen 112 | 113 | const userAgent = navigator.userAgent 114 | 115 | export const isIE = (userAgent.indexOf('Trident') >= 0) 116 | export const isEdge = (userAgent.indexOf('Edge/') >= 0) 117 | export const isEdgeOrIE = isIE || isEdge 118 | 119 | export const isOpera = (userAgent.indexOf('Opera') >= 0) 120 | export const isFirefox = (userAgent.indexOf('Firefox') >= 0) 121 | export const isWebKit = (userAgent.indexOf('AppleWebKit') >= 0) 122 | export const isChrome = (userAgent.indexOf('Chrome') >= 0) 123 | export const isSafari = (!isChrome && (userAgent.indexOf('Safari') >= 0)) 124 | export const isWebkitWebView = (!isChrome && !isSafari && isWebKit) 125 | export const isIPad = (userAgent.indexOf('iPad') >= 0) 126 | export const isEdgeWebView = isEdge && (userAgent.indexOf('WebView/') >= 0) 127 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Intellij ### 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # Generated files 13 | .idea/**/contentModel.xml 14 | 15 | # Sensitive or high-churn files 16 | .idea/**/dataSources/ 17 | .idea/**/dataSources.ids 18 | .idea/**/dataSources.local.xml 19 | .idea/**/sqlDataSources.xml 20 | .idea/**/dynamic.xml 21 | .idea/**/uiDesigner.xml 22 | .idea/**/dbnavigator.xml 23 | 24 | # Gradle 25 | .idea/**/gradle.xml 26 | .idea/**/libraries 27 | 28 | # Gradle and Maven with auto-import 29 | # When using Gradle or Maven with auto-import, you should exclude module files, 30 | # since they will be recreated, and may cause churn. Uncomment if using 31 | # auto-import. 32 | # .idea/artifacts 33 | # .idea/compiler.xml 34 | # .idea/jarRepositories.xml 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | 74 | ### Intellij Patch ### 75 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 76 | 77 | # *.iml 78 | # modules.xml 79 | # .idea/misc.xml 80 | # *.ipr 81 | 82 | # Sonarlint plugin 83 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 84 | .idea/**/sonarlint/ 85 | 86 | # SonarQube Plugin 87 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 88 | .idea/**/sonarIssues.xml 89 | 90 | # Markdown Navigator plugin 91 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 92 | .idea/**/markdown-navigator.xml 93 | .idea/**/markdown-navigator-enh.xml 94 | .idea/**/markdown-navigator/ 95 | 96 | # Cache file creation bug 97 | # See https://youtrack.jetbrains.com/issue/JBR-2257 98 | .idea/$CACHE_FILE$ 99 | 100 | # CodeStream plugin 101 | # https://plugins.jetbrains.com/plugin/12206-codestream 102 | .idea/codestream.xml 103 | 104 | ### Node ### 105 | # Logs 106 | logs 107 | *.log 108 | npm-debug.log* 109 | yarn-debug.log* 110 | yarn-error.log* 111 | lerna-debug.log* 112 | 113 | # Diagnostic reports (https://nodejs.org/api/report.html) 114 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 115 | 116 | # Runtime data 117 | pids 118 | *.pid 119 | *.seed 120 | *.pid.lock 121 | 122 | # Directory for instrumented libs generated by jscoverage/JSCover 123 | lib-cov 124 | 125 | # Coverage directory used by tools like istanbul 126 | coverage 127 | *.lcov 128 | 129 | # nyc test coverage 130 | .nyc_output 131 | 132 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 133 | .grunt 134 | 135 | # Bower dependency directory (https://bower.io/) 136 | bower_components 137 | 138 | # node-waf configuration 139 | .lock-wscript 140 | 141 | # Compiled binary addons (https://nodejs.org/api/addons.html) 142 | build/Release 143 | 144 | # Dependency directories 145 | node_modules/ 146 | jspm_packages/ 147 | 148 | # TypeScript v1 declaration files 149 | typings/ 150 | 151 | # TypeScript cache 152 | *.tsbuildinfo 153 | 154 | # Optional npm cache directory 155 | .npm 156 | 157 | # Optional eslint cache 158 | .eslintcache 159 | 160 | # Microbundle cache 161 | .rpt2_cache/ 162 | .rts2_cache_cjs/ 163 | .rts2_cache_es/ 164 | .rts2_cache_umd/ 165 | 166 | # Optional REPL history 167 | .node_repl_history 168 | 169 | # Output of 'npm pack' 170 | *.tgz 171 | 172 | # Yarn Integrity file 173 | .yarn-integrity 174 | 175 | # dotenv environment variables file 176 | .env 177 | .env.test 178 | .env*.local 179 | 180 | # parcel-bundler cache (https://parceljs.org/) 181 | .cache 182 | .parcel-cache 183 | 184 | # Next.js build output 185 | .next 186 | 187 | # Nuxt.js build / generate output 188 | .nuxt 189 | dist 190 | build/ 191 | *.map 192 | index.js 193 | main.js 194 | 195 | # Gatsby files 196 | .cache/ 197 | # Comment in the public line in if your project uses Gatsby and not Next.js 198 | # https://nextjs.org/blog/next-9-1#public-directory-support 199 | # public 200 | 201 | # vuepress build output 202 | .vuepress/dist 203 | 204 | # Serverless directories 205 | .serverless/ 206 | 207 | # FuseBox cache 208 | .fusebox/ 209 | 210 | # DynamoDB Local files 211 | .dynamodb/ 212 | 213 | # TernJS port file 214 | .tern-port 215 | 216 | # Stores VSCode versions used for testing VSCode extensions 217 | .vscode-test 218 | -------------------------------------------------------------------------------- /src/base/browser/mouseEvent.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import * as browser from 'base/browser/browser' 7 | import * as platform from 'base/common/platform' 8 | 9 | export interface IMouseEvent { 10 | readonly browserEvent: MouseEvent; 11 | readonly leftButton: boolean; 12 | readonly middleButton: boolean; 13 | readonly rightButton: boolean; 14 | readonly target: HTMLElement; 15 | readonly detail: number; 16 | readonly posx: number; 17 | readonly posy: number; 18 | readonly ctrlKey: boolean; 19 | readonly shiftKey: boolean; 20 | readonly altKey: boolean; 21 | readonly metaKey: boolean; 22 | readonly timestamp: number; 23 | 24 | preventDefault(): void; 25 | stopPropagation(): void; 26 | } 27 | 28 | export class StandardMouseEvent implements IMouseEvent { 29 | public readonly browserEvent: MouseEvent 30 | 31 | public readonly leftButton: boolean 32 | public readonly middleButton: boolean 33 | public readonly rightButton: boolean 34 | public readonly target: HTMLElement 35 | public detail: number 36 | public readonly posx: number 37 | public readonly posy: number 38 | public readonly ctrlKey: boolean 39 | public readonly shiftKey: boolean 40 | public readonly altKey: boolean 41 | public readonly metaKey: boolean 42 | public readonly timestamp: number 43 | 44 | constructor(e: MouseEvent) { 45 | this.timestamp = Date.now() 46 | this.browserEvent = e 47 | this.leftButton = e.button === 0 48 | this.middleButton = e.button === 1 49 | this.rightButton = e.button === 2 50 | 51 | this.target = e.target 52 | 53 | this.detail = e.detail || 1 54 | if (e.type === 'dblclick') { 55 | this.detail = 2 56 | } 57 | this.ctrlKey = e.ctrlKey 58 | this.shiftKey = e.shiftKey 59 | this.altKey = e.altKey 60 | this.metaKey = e.metaKey 61 | 62 | if (typeof e.pageX === 'number') { 63 | this.posx = e.pageX 64 | this.posy = e.pageY 65 | } else { 66 | // Probably hit by MSGestureEvent 67 | this.posx = e.clientX + document.body.scrollLeft + document.documentElement?.scrollLeft 68 | this.posy = e.clientY + document.body.scrollTop + document.documentElement?.scrollTop 69 | } 70 | } 71 | 72 | public preventDefault(): void { 73 | if (this.browserEvent.preventDefault) { 74 | this.browserEvent.preventDefault() 75 | } 76 | } 77 | 78 | public stopPropagation(): void { 79 | if (this.browserEvent.stopPropagation) { 80 | this.browserEvent.stopPropagation() 81 | } 82 | } 83 | } 84 | 85 | export interface IDataTransfer { 86 | dropEffect: string; 87 | effectAllowed: string; 88 | types: any[]; 89 | files: any[]; 90 | 91 | setData(type: string, data: string): void; 92 | setDragImage(image: any, x: number, y: number): void; 93 | 94 | getData(type: string): string; 95 | clearData(types?: string[]): void; 96 | } 97 | 98 | export class DragMouseEvent extends StandardMouseEvent { 99 | public readonly dataTransfer: IDataTransfer 100 | 101 | constructor(e: MouseEvent) { 102 | super(e) 103 | this.dataTransfer = (e).dataTransfer 104 | } 105 | } 106 | 107 | export interface IMouseWheelEvent extends MouseEvent { 108 | readonly wheelDelta: number; 109 | } 110 | 111 | interface IWebKitMouseWheelEvent { 112 | wheelDeltaY: number; 113 | wheelDeltaX: number; 114 | } 115 | 116 | interface IGeckoMouseWheelEvent { 117 | HORIZONTAL_AXIS: number; 118 | VERTICAL_AXIS: number; 119 | axis: number; 120 | detail: number; 121 | } 122 | 123 | export class StandardWheelEvent { 124 | public readonly browserEvent: IMouseWheelEvent | null 125 | public readonly deltaY: number 126 | public readonly deltaX: number 127 | public readonly target: Node 128 | 129 | constructor(e: IMouseWheelEvent | null, deltaX = 0, deltaY = 0) { 130 | this.browserEvent = e || null 131 | this.target = e ? (e.target || (e).targetNode || e.srcElement) : null 132 | 133 | this.deltaY = deltaY 134 | this.deltaX = deltaX 135 | 136 | if (e) { 137 | const e1 = e 138 | const e2 = e 139 | 140 | // vertical delta scroll 141 | if (typeof e1.wheelDeltaY !== 'undefined') { 142 | this.deltaY = e1.wheelDeltaY / 120 143 | } else if (typeof e2.VERTICAL_AXIS !== 'undefined' && e2.axis === e2.VERTICAL_AXIS) { 144 | this.deltaY = -e2.detail / 3 145 | } 146 | 147 | // horizontal delta scroll 148 | if (typeof e1.wheelDeltaX !== 'undefined') { 149 | if (browser.isSafari && platform.isWindows) { 150 | this.deltaX = -(e1.wheelDeltaX / 120) 151 | } else { 152 | this.deltaX = e1.wheelDeltaX / 120 153 | } 154 | } else if (typeof e2.HORIZONTAL_AXIS !== 'undefined' && e2.axis === e2.HORIZONTAL_AXIS) { 155 | this.deltaX = -e.detail / 3 156 | } 157 | 158 | // Assume a vertical scroll if nothing else worked 159 | if (this.deltaY === 0 && this.deltaX === 0 && e.wheelDelta) { 160 | this.deltaY = e.wheelDelta / 120 161 | } 162 | } 163 | } 164 | 165 | public preventDefault(): void { 166 | if (this.browserEvent) { 167 | if (this.browserEvent.preventDefault) { 168 | this.browserEvent.preventDefault() 169 | } 170 | } 171 | } 172 | 173 | public stopPropagation(): void { 174 | if (this.browserEvent) { 175 | if (this.browserEvent.stopPropagation) { 176 | this.browserEvent.stopPropagation() 177 | } 178 | } 179 | } 180 | } -------------------------------------------------------------------------------- /src/base/common/iterator.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | export interface IteratorDefinedResult { 7 | readonly done: false; 8 | readonly value: T; 9 | } 10 | export interface IteratorUndefinedResult { 11 | readonly done: true; 12 | readonly value: undefined; 13 | } 14 | export const FIN: IteratorUndefinedResult = { done: true, value: undefined } 15 | export type IteratorResult = IteratorDefinedResult | IteratorUndefinedResult; 16 | 17 | export interface Iterator { 18 | next(): IteratorResult; 19 | } 20 | 21 | export module Iterator { 22 | const _empty: Iterator = { 23 | next() { 24 | return FIN 25 | } 26 | } 27 | 28 | export function empty(): Iterator { 29 | return _empty 30 | } 31 | 32 | export function fromArray(array: T[], index = 0, length = array.length): Iterator { 33 | return { 34 | next(): IteratorResult { 35 | if (index >= length) { 36 | return FIN 37 | } 38 | 39 | return { done: false, value: array[index++] } 40 | } 41 | } 42 | } 43 | 44 | export function from(elements: Iterator | T[] | undefined): Iterator { 45 | if (!elements) { 46 | return Iterator.empty() 47 | } else if (Array.isArray(elements)) { 48 | return Iterator.fromArray(elements) 49 | } else { 50 | return elements 51 | } 52 | } 53 | 54 | export function map(iterator: Iterator, fn: (t: T) => R): Iterator { 55 | return { 56 | next() { 57 | const element = iterator.next() 58 | if (element.done) { 59 | return FIN 60 | } else { 61 | return { done: false, value: fn(element.value) } 62 | } 63 | } 64 | } 65 | } 66 | 67 | export function filter(iterator: Iterator, fn: (t: T) => boolean): Iterator { 68 | return { 69 | next() { 70 | while (true) { 71 | const element = iterator.next() 72 | if (element.done) { 73 | return FIN 74 | } 75 | if (fn(element.value)) { 76 | return { done: false, value: element.value } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | export function forEach(iterator: Iterator, fn: (t: T) => void): void { 84 | for (let next = iterator.next(); !next.done; next = iterator.next()) { 85 | fn(next.value) 86 | } 87 | } 88 | 89 | export function collect(iterator: Iterator): T[] { 90 | const result: T[] = [] 91 | forEach(iterator, value => result.push(value)) 92 | return result 93 | } 94 | } 95 | 96 | export type ISequence = Iterator | T[]; 97 | 98 | export function getSequenceIterator(arg: Iterator | T[]): Iterator { 99 | if (Array.isArray(arg)) { 100 | return Iterator.fromArray(arg) 101 | } else { 102 | return arg 103 | } 104 | } 105 | 106 | export interface INextIterator { 107 | next(): T | null; 108 | } 109 | 110 | export class ArrayIterator implements INextIterator { 111 | private items: T[] 112 | protected start: number 113 | protected end: number 114 | protected index: number 115 | 116 | constructor(items: T[], start: number = 0, end: number = items.length, index = start - 1) { 117 | this.items = items 118 | this.start = start 119 | this.end = end 120 | this.index = index 121 | } 122 | 123 | public first(): T | null { 124 | this.index = this.start 125 | return this.current() 126 | } 127 | 128 | public next(): T | null { 129 | this.index = Math.min(this.index + 1, this.end) 130 | return this.current() 131 | } 132 | 133 | protected current(): T | null { 134 | if (this.index === this.start - 1 || this.index === this.end) { 135 | return null 136 | } 137 | 138 | return this.items[this.index] 139 | } 140 | } 141 | 142 | export interface INavigator extends INextIterator { 143 | current(): T | null; 144 | previous(): T | null; 145 | parent(): T | null; 146 | first(): T | null; 147 | last(): T | null; 148 | next(): T | null; 149 | } 150 | 151 | export class ArrayNavigator extends ArrayIterator implements INavigator { 152 | constructor(items: T[], start: number = 0, end: number = items.length, index = start - 1) { 153 | super(items, start, end, index) 154 | } 155 | 156 | public current(): T | null { 157 | return super.current() 158 | } 159 | 160 | public previous(): T | null { 161 | this.index = Math.max(this.index - 1, this.start - 1) 162 | return this.current() 163 | } 164 | 165 | public first(): T | null { 166 | this.index = this.start 167 | return this.current() 168 | } 169 | 170 | public last(): T | null { 171 | this.index = this.end - 1 172 | return this.current() 173 | } 174 | 175 | public parent(): T | null { 176 | return null 177 | } 178 | } 179 | 180 | export class MappedIterator implements INextIterator { 181 | constructor(protected iterator: INextIterator, protected fn: (item: T | null) => R) { 182 | // noop 183 | } 184 | 185 | next() { return this.fn(this.iterator.next()) } 186 | } 187 | 188 | export class MappedNavigator extends MappedIterator implements INavigator { 189 | constructor(protected navigator: INavigator, fn: (item: T | null) => R) { 190 | super(navigator, fn) 191 | } 192 | 193 | current() { return this.fn(this.navigator.current()) } 194 | previous() { return this.fn(this.navigator.previous()) } 195 | parent() { return this.fn(this.navigator.parent()) } 196 | first() { return this.fn(this.navigator.first()) } 197 | last() { return this.fn(this.navigator.last()) } 198 | next() { return this.fn(this.navigator.next()) } 199 | } -------------------------------------------------------------------------------- /example/main.js: -------------------------------------------------------------------------------- 1 | // Modules to control application life and create native browser window 2 | const { app, BrowserWindow, Menu, nativeImage, ipcMain, nativeTheme } = require('electron') 3 | const { setupTitlebar, attachTitlebarToWindow } = require('custom-electron-titlebar/main') 4 | const path = require('path') 5 | 6 | // Setup the titlebar 7 | setupTitlebar() 8 | 9 | function createWindow() { 10 | // Create the browser window. 11 | const mainWindow = new BrowserWindow({ 12 | width: 1000, 13 | height: 600, 14 | frame: false, // Use to linux 15 | titleBarStyle: 'hidden', 16 | titleBarOverlay: true, 17 | webPreferences: { 18 | sandbox: false, 19 | preload: path.join(__dirname, 'preload.js') 20 | } 21 | }) 22 | 23 | /* const menu = Menu.buildFromTemplate(exampleMenuTemplate) 24 | Menu.setApplicationMenu(menu) */ 25 | 26 | // and load the index.html of the app. 27 | // mainWindow.loadFile('index.html') 28 | mainWindow.loadURL('https://github.com') 29 | 30 | // Open the DevTools. 31 | // mainWindow.webContents.openDevTools() 32 | 33 | // Attach listeners 34 | attachTitlebarToWindow(mainWindow) 35 | } 36 | 37 | ipcMain.handle('dark-mode:toggle', () => { 38 | if (nativeTheme.shouldUseDarkColors) { 39 | nativeTheme.themeSource = 'light' 40 | } else { 41 | nativeTheme.themeSource = 'dark' 42 | } 43 | return nativeTheme.shouldUseDarkColors 44 | }) 45 | 46 | ipcMain.handle('dark-mode:system', () => { 47 | nativeTheme.themeSource = 'system' 48 | }) 49 | 50 | // This method will be called when Electron has finished 51 | // initialization and is ready to create browser windows. 52 | // Some APIs can only be used after this event occurs. 53 | app.whenReady().then(() => { 54 | createWindow() 55 | 56 | app.on('activate', function () { 57 | // On macOS it's common to re-create a window in the app when the 58 | // dock icon is clicked and there are no other windows open. 59 | if (BrowserWindow.getAllWindows().length === 0) createWindow() 60 | }) 61 | }) 62 | 63 | // Quit when all windows are closed, except on macOS. There, it's common 64 | // for applications and their menu bar to stay active until the user quits 65 | // explicitly with Cmd + Q. 66 | app.on('window-all-closed', function () { 67 | if (process.platform !== 'darwin') app.quit() 68 | }) 69 | 70 | // In this file you can include the rest of your app's specific main process 71 | // code. You can also put them in separate files and require them here. 72 | 73 | 74 | // Custom menu 75 | const exampleMenuTemplate = [ 76 | { 77 | label: 'Simple O&ptions', 78 | submenu: [ 79 | { 80 | label: 'Quit', 81 | click: () => app.quit() 82 | }, 83 | { 84 | label: 'Radio1', 85 | type: 'radio', 86 | checked: true 87 | }, 88 | { 89 | label: 'Radio2', 90 | type: 'radio' 91 | }, 92 | { 93 | label: 'Check&box1', 94 | type: 'checkbox', 95 | checked: true, 96 | click: (item) => { 97 | console.log('item is checked? ' + item.checked) 98 | } 99 | }, 100 | { type: 'separator' }, 101 | { 102 | label: 'Che&ckbox2', 103 | type: 'checkbox', 104 | checked: false, 105 | click: (item) => { 106 | console.log('item is checked? ' + item.checked) 107 | } 108 | } 109 | ] 110 | }, 111 | { 112 | label: 'With &Icons', 113 | submenu: [ 114 | { 115 | icon: nativeImage.createFromPath(path.resolve('example/assets', 'home.png')), 116 | label: 'Go to &Home using Native Image' 117 | }, 118 | { 119 | icon: path.resolve('example/assets', 'run.png'), 120 | label: 'Run using string', 121 | submenu: [ 122 | { 123 | label: 'Submenu of run' 124 | }, 125 | { 126 | label: 'Print', 127 | accelerator: 'CmdOrCtrl+P' 128 | }, 129 | { 130 | type: 'separator' 131 | }, 132 | { 133 | label: 'Item 2 of submenu of run' 134 | } 135 | ] 136 | } 137 | ] 138 | }, 139 | { 140 | label: 'A&dvanced Options', 141 | submenu: [ 142 | { 143 | label: 'Quit', 144 | click: () => app.quit() 145 | }, 146 | { 147 | label: 'Radio1', 148 | type: 'radio', 149 | checked: true 150 | }, 151 | { 152 | label: 'Radio2', 153 | type: 'radio' 154 | }, 155 | { 156 | label: 'Checkbox1', 157 | type: 'checkbox', 158 | checked: true, 159 | click: (item) => { 160 | console.log('item is checked? ' + item.checked) 161 | } 162 | }, 163 | { type: 'separator' }, 164 | { 165 | label: 'Checkbox2', 166 | type: 'checkbox', 167 | checked: false, 168 | click: (item) => { 169 | console.log('item is checked? ' + item.checked) 170 | } 171 | }, 172 | { 173 | label: 'Radio Test', 174 | submenu: [ 175 | { 176 | label: 'S&le Checkbox', 177 | type: 'checkbox', 178 | checked: true 179 | }, 180 | { 181 | label: 'Radio1', 182 | checked: true, 183 | type: 'radio' 184 | }, 185 | { 186 | label: 'Radio2', 187 | type: 'radio' 188 | }, 189 | { 190 | label: 'Radio3', 191 | type: 'radio' 192 | }, 193 | { type: 'separator' }, 194 | { 195 | label: 'Radio1', 196 | checked: true, 197 | type: 'radio' 198 | }, 199 | { 200 | label: 'Radio2', 201 | type: 'radio' 202 | }, 203 | { 204 | label: 'Radio3', 205 | type: 'radio' 206 | } 207 | ] 208 | }, 209 | { 210 | label: 'zoomIn', 211 | role: 'zoomIn' 212 | }, 213 | { 214 | label: 'zoomOut', 215 | role: 'zoomOut' 216 | }, 217 | { 218 | label: 'Radio1', 219 | type: 'radio' 220 | }, 221 | { 222 | label: 'Radio2', 223 | checked: true, 224 | type: 'radio' 225 | } 226 | ] 227 | }, 228 | { 229 | label: '&View', 230 | submenu: [ 231 | { role: 'reload' }, 232 | { role: 'forceReload' }, 233 | { type: 'separator' }, 234 | { role: 'zoomIn' }, 235 | { role: 'zoomOut' }, 236 | { role: 'resetZoom' }, 237 | { role: 'toggleDevTools', icon: path.resolve('example/assets', 'terminal.png') } 238 | ] 239 | } 240 | ] 241 | -------------------------------------------------------------------------------- /src/menubar/menu/submenu.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) AlexTorresDev. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { MenuItem } from 'electron' 7 | import { applyFill } from 'consts' 8 | import { CETMenuItem } from './item' 9 | import { IDisposable, dispose } from 'base/common/lifecycle' 10 | import { $, EventHelper, EventLike, EventType, addClass, addClasses, addDisposableListener, append, hasClass, isAncestor } from 'base/common/dom' 11 | import { StandardKeyboardEvent } from 'base/browser/keyboardEvent' 12 | import { CETMenu, IMenuOptions } from './index' 13 | import { RunOnceScheduler } from 'base/common/async' 14 | import { MenuBarOptions } from 'menubar/menubar-options' 15 | import { KeyCode } from 'base/common/keyCodes' 16 | import { IMenuIcons } from 'menubar' 17 | 18 | export interface ISubMenuData { 19 | parent: CETMenu; 20 | submenu?: CETMenu; 21 | } 22 | 23 | export class CETSubMenu extends CETMenuItem { 24 | private mySubmenu?: CETMenu | null 25 | private submenuContainer?: HTMLElement 26 | private submenuIndicator?: HTMLElement 27 | private submenuDisposables: IDisposable[] = [] 28 | private mouseOver = false 29 | private showScheduler: RunOnceScheduler 30 | private hideScheduler: RunOnceScheduler 31 | private _closeSubMenu = () => { } 32 | 33 | constructor(item: MenuItem, private submenuIcons: IMenuIcons, private submenuItems: MenuItem[], private parentData: ISubMenuData, private submenuParentOptions: MenuBarOptions, private submenuOptions: IMenuOptions, closeSubMenu = () => { }) { 34 | super(item, submenuIcons, submenuParentOptions, submenuOptions) 35 | 36 | this._closeSubMenu = closeSubMenu 37 | 38 | this.showScheduler = new RunOnceScheduler(() => { 39 | if (this.mouseOver) { 40 | this.cleanupExistingSubmenu(false) 41 | this.createSubmenu(false) 42 | } 43 | }, 250) 44 | 45 | this.hideScheduler = new RunOnceScheduler(() => { 46 | if (this.element && (!isAncestor(document.activeElement, this.element) && this.parentData.submenu === this.mySubmenu)) { 47 | this.parentData.parent.focus(false) 48 | this.cleanupExistingSubmenu(true) 49 | } 50 | }, 750) 51 | } 52 | 53 | render(el: HTMLElement): void { 54 | super.render(el) 55 | 56 | if (!this.itemElement) { 57 | return 58 | } 59 | 60 | addClass(this.itemElement, 'cet-submenu-item') 61 | this.itemElement.setAttribute('aria-haspopup', 'true') 62 | 63 | this.submenuIndicator = append(this.itemElement, $('span.cet-submenu-indicator')) 64 | this.submenuIndicator.innerHTML = this.submenuIcons.submenuIndicator 65 | this.submenuIndicator.setAttribute('aria-hidden', 'true') 66 | 67 | applyFill(this.submenuIndicator, this.menuStyle?.svgColor, this.menuStyle?.foregroundColor) 68 | 69 | if (this.element) { 70 | addDisposableListener(this.element, EventType.KEY_UP, e => { 71 | const event = new StandardKeyboardEvent(e) 72 | if (event.equals(KeyCode.RightArrow) || event.equals(KeyCode.Enter)) { 73 | EventHelper.stop(e, true) 74 | 75 | this.createSubmenu(true) 76 | } 77 | }) 78 | 79 | addDisposableListener(this.element, EventType.KEY_DOWN, e => { 80 | const event = new StandardKeyboardEvent(e) 81 | if (event.equals(KeyCode.RightArrow) || event.equals(KeyCode.Enter)) { 82 | EventHelper.stop(e, true) 83 | } 84 | }) 85 | 86 | addDisposableListener(this.element, EventType.MOUSE_OVER, e => { 87 | if (!this.mouseOver) { 88 | this.mouseOver = true 89 | 90 | this.showScheduler.schedule() 91 | } 92 | }) 93 | 94 | addDisposableListener(this.element, EventType.MOUSE_LEAVE, e => { 95 | this.mouseOver = false 96 | }) 97 | 98 | addDisposableListener(this.element, EventType.FOCUS_OUT, e => { 99 | if (this.element && !isAncestor(document.activeElement, this.element)) { 100 | this.hideScheduler.schedule() 101 | } 102 | }) 103 | } 104 | } 105 | 106 | private cleanupExistingSubmenu(force: boolean): void { 107 | if (this.parentData.submenu && (force || (this.parentData.submenu !== this.mySubmenu))) { 108 | this.parentData.submenu.dispose() 109 | this.parentData.submenu = undefined 110 | 111 | if (this.submenuContainer) { 112 | this.submenuContainer = undefined 113 | } 114 | } 115 | } 116 | 117 | private createSubmenu(selectFirstItem = true): void { 118 | if (!this.itemElement) { 119 | return 120 | } 121 | 122 | if (this.element) { 123 | if (!this.parentData.submenu) { 124 | this.submenuContainer = append(this.element, $('.cet-submenu')) 125 | addClasses(this.submenuContainer, 'cet-menubar-menu-container') 126 | 127 | this.parentData.submenu = new CETMenu(this.submenuContainer, this.submenuIcons, this.submenuParentOptions, this.submenuOptions, this._closeSubMenu) 128 | this.parentData.submenu.createMenu(this.submenuItems) 129 | 130 | if (this.menuStyle) { 131 | this.parentData.submenu.applyStyle(this.menuStyle) 132 | } 133 | 134 | const boundingRect = this.element.getBoundingClientRect() 135 | const childBoundingRect = this.submenuContainer.getBoundingClientRect() 136 | const computedStyles = getComputedStyle(this.parentData.parent.container) 137 | const paddingTop = parseFloat(computedStyles.paddingTop || '0') || 0 138 | 139 | if (window.innerWidth <= boundingRect.right + childBoundingRect.width) { 140 | this.submenuContainer.style.left = '10px' 141 | this.submenuContainer.style.top = `${this.element.offsetTop + boundingRect.height}px` 142 | } else { 143 | this.submenuContainer.style.left = `${this.element.offsetWidth}px` 144 | this.submenuContainer.style.top = `${this.element.offsetTop - paddingTop}px` 145 | } 146 | 147 | this.submenuDisposables.push(addDisposableListener(this.submenuContainer, EventType.KEY_UP, e => { 148 | const event = new StandardKeyboardEvent(e) 149 | if (event.equals(KeyCode.LeftArrow)) { 150 | EventHelper.stop(e, true) 151 | 152 | this.parentData.parent.focus() 153 | 154 | if (this.parentData.submenu) { 155 | this.parentData.submenu.dispose() 156 | this.parentData.submenu = undefined 157 | } 158 | 159 | this.submenuDisposables = dispose(this.submenuDisposables) 160 | this.submenuContainer = undefined 161 | } 162 | })) 163 | 164 | this.submenuDisposables.push(addDisposableListener(this.submenuContainer, EventType.KEY_DOWN, e => { 165 | const event = new StandardKeyboardEvent(e) 166 | if (event.equals(KeyCode.LeftArrow)) { 167 | EventHelper.stop(e, true) 168 | } 169 | })) 170 | 171 | this.submenuDisposables.push(this.parentData.submenu.onDidCancel(() => { 172 | this.parentData.parent.focus() 173 | 174 | if (this.parentData.submenu) { 175 | this.parentData.submenu.dispose() 176 | this.parentData.submenu = undefined 177 | } 178 | 179 | this.submenuDisposables = dispose(this.submenuDisposables) 180 | this.submenuContainer = undefined 181 | })) 182 | 183 | this.parentData.submenu.focus(selectFirstItem) 184 | 185 | this.mySubmenu = this.parentData.submenu 186 | } else { 187 | this.parentData.submenu.focus(false) 188 | } 189 | } 190 | } 191 | 192 | protected applyStyle(): void { 193 | super.applyStyle() 194 | 195 | if (!this.menuStyle) return 196 | 197 | const isSelected = this.element && hasClass(this.element, 'focused') 198 | const fgColor = isSelected && this.menuStyle.selectionForegroundColor ? this.menuStyle.selectionForegroundColor : this.menuStyle.foregroundColor 199 | applyFill(this.submenuIndicator, this.submenuParentOptions.svgColor, fgColor) 200 | 201 | if (this.parentData.submenu) this.parentData.submenu.applyStyle(this.menuStyle) 202 | } 203 | 204 | onClick(e: EventLike): void { 205 | // stop clicking from trying to run an action 206 | EventHelper.stop(e, true) 207 | 208 | this.cleanupExistingSubmenu(false) 209 | this.createSubmenu(false) 210 | } 211 | 212 | dispose(): void { 213 | super.dispose() 214 | 215 | this.hideScheduler.dispose() 216 | 217 | if (this.mySubmenu) { 218 | this.mySubmenu.dispose() 219 | this.mySubmenu = null 220 | } 221 | 222 | if (this.submenuContainer) { 223 | this.submenuDisposables = dispose(this.submenuDisposables) 224 | this.submenuContainer = undefined 225 | } 226 | } 227 | } -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) AlexTorresDev. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { Color } from 'base/common/color' 7 | import { PlatformToString, isMacintosh, isWindows, platform } from 'base/common/platform' 8 | import { IMenuIcons } from 'menubar' 9 | 10 | export const INACTIVE_FOREGROUND_DARK = Color.fromHex('#222222') 11 | export const ACTIVE_FOREGROUND_DARK = Color.fromHex('#333333') 12 | export const INACTIVE_FOREGROUND = Color.fromHex('#EEEEEE') 13 | export const ACTIVE_FOREGROUND = Color.fromHex('#FFFFFF') 14 | export const DEFAULT_ITEM_SELECTOR = Color.fromHex('#0000001F') 15 | 16 | export const IS_MAC_BIGSUR_OR_LATER = isMacintosh && parseInt(process.getSystemVersion().split('.')[0]) >= 11 17 | export const BOTTOM_TITLEBAR_HEIGHT = 60 18 | export const TOP_TITLEBAR_HEIGHT_MAC = IS_MAC_BIGSUR_OR_LATER ? 28 : 22 19 | export const TOP_TITLEBAR_HEIGHT_WIN = 30 20 | 21 | export const WINDOW_MIN_WIDTH = 400 22 | export const WINDOW_MIN_HEIGHT = 270 23 | 24 | export const MENU_MNEMONIC_REGEX = /\(&([^\s&])\)|(^|[^&])&([^\s&])/ 25 | export const MENU_ESCAPED_MNEMONIC_REGEX = /(&)?(&)([^\s&])/g 26 | 27 | interface ITitlebarIcons extends IMenuIcons { 28 | linux: { 29 | minimize: string 30 | maximize: string 31 | restore: string 32 | close: string 33 | }, 34 | freebsd: { 35 | minimize: string 36 | maximize: string 37 | restore: string 38 | close: string 39 | }, 40 | windows: { 41 | minimize: string 42 | maximize: string 43 | restore: string 44 | close: string 45 | } 46 | } 47 | 48 | export const menuIcons: ITitlebarIcons = { 49 | submenuIndicator: '', 50 | checkbox: '', 51 | radioChecked: '', 52 | radioUnchecked: '', 53 | linux: { 54 | minimize: '', 55 | maximize: '', 56 | restore: '', 57 | close: '' 58 | }, 59 | freebsd: { 60 | minimize: '', 61 | maximize: '', 62 | restore: '', 63 | close: '' 64 | }, 65 | windows: { 66 | minimize: '', 67 | maximize: '', 68 | restore: '', 69 | close: '' 70 | } 71 | } 72 | 73 | export function getPx(value: number): string { 74 | return `${value}px` 75 | } 76 | 77 | /** 78 | * Handles mnemonics for menu items. Depending on OS: 79 | * - Windows: Supported via & character (replace && with &) 80 | * - Linux: Supported via & character (replace && with &) 81 | * - FreeBSD: Supported via & character (replace && with &) 82 | * - macOS: Unsupported (replace && with empty string) 83 | */ 84 | export function mnemonicMenuLabel(label: string, forceDisableMnemonics?: boolean): string { 85 | if (isMacintosh || forceDisableMnemonics) { 86 | return label.replace(/\(&&\w\)|&&/g, '').replace(/&/g, isMacintosh ? '&' : '&&') 87 | } 88 | 89 | return label.replace(/&&|&/g, m => m === '&' ? '&&' : '&') 90 | } 91 | 92 | /** 93 | * Handles mnemonics for buttons. Depending on OS: 94 | * - Windows: Supported via & character (replace && with & and & with && for escaping) 95 | * - Linux: Supported via _ character (replace && with _) 96 | * - FreeBSD: Supported via _ character (replace && with _) 97 | * - macOS: Unsupported (replace && with empty string) 98 | */ 99 | export function mnemonicButtonLabel(label: string, forceDisableMnemonics?: boolean): string { 100 | if (isMacintosh || forceDisableMnemonics) { 101 | return label.replace(/\(&&\w\)|&&/g, '') 102 | } 103 | 104 | if (isWindows) { 105 | return label.replace(/&&|&/g, m => m === '&' ? '&&' : '&') 106 | } 107 | 108 | return label.replace(/&&/g, '_') 109 | } 110 | 111 | export function cleanMnemonic(label: string): string { 112 | const regex = MENU_MNEMONIC_REGEX 113 | 114 | const matches = regex.exec(label) 115 | if (!matches) { 116 | return label 117 | } 118 | 119 | const mnemonicInText = !matches[1] 120 | 121 | return label.replace(regex, mnemonicInText ? '$2$3' : '').trim() 122 | } 123 | 124 | export function parseAccelerator(accelerator: Electron.Accelerator | string): string { 125 | let acc = accelerator.toString() 126 | 127 | if (!isMacintosh) { 128 | acc = acc.replace(/(Cmd)|(Command)/gi, '') 129 | } else { 130 | acc = acc.replace(/(Ctrl)|(Control)/gi, '') 131 | } 132 | 133 | acc = acc.replace(/(Or)/gi, '') 134 | 135 | return acc 136 | } 137 | 138 | export function applyFill(element: HTMLElement | undefined | null, svgColor: Color | undefined, fgColor: Color | undefined, color = true) { 139 | let fillColor = '' 140 | 141 | if (svgColor) fillColor = svgColor.toString() 142 | else if (fgColor) fillColor = fgColor.toString() 143 | 144 | if (element && element !== null) { 145 | if (color) element.style.color = fillColor 146 | else element.style.backgroundColor = fillColor 147 | } 148 | } 149 | 150 | export function loadWindowIcons(icons: string | undefined): any { 151 | if (!icons) return 152 | 153 | const jWindowsIcons = require(icons) 154 | 155 | return { 156 | icons: jWindowsIcons, 157 | platformIcons: jWindowsIcons[PlatformToString(platform).toLocaleLowerCase()] 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/base/browser/keyboardEvent.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { KeyCode, KeyCodeUtils, KeyMod, SimpleKeybinding } from 'base/common/keyCodes' 7 | import * as platform from 'base/common/platform' 8 | 9 | const KEY_CODE_MAP: { [keyCode: number]: KeyCode } = new Array(230) 10 | const INVERSE_KEY_CODE_MAP: KeyCode[] = new Array(KeyCode.MAX_VALUE); 11 | 12 | (function () { 13 | for (let i = 0; i < INVERSE_KEY_CODE_MAP.length; i++) { 14 | INVERSE_KEY_CODE_MAP[i] = 0 15 | } 16 | 17 | function define(code: number, keyCode: KeyCode): void { 18 | KEY_CODE_MAP[code] = keyCode 19 | INVERSE_KEY_CODE_MAP[keyCode] = code 20 | } 21 | 22 | define(3, KeyCode.PauseBreak) // VK_CANCEL 0x03 Control-break processing 23 | define(8, KeyCode.Backspace) 24 | define(9, KeyCode.Tab) 25 | define(13, KeyCode.Enter) 26 | define(16, KeyCode.Shift) 27 | define(17, KeyCode.Ctrl) 28 | define(18, KeyCode.Alt) 29 | define(19, KeyCode.PauseBreak) 30 | define(20, KeyCode.CapsLock) 31 | define(27, KeyCode.Escape) 32 | define(32, KeyCode.Space) 33 | define(33, KeyCode.PageUp) 34 | define(34, KeyCode.PageDown) 35 | define(35, KeyCode.End) 36 | define(36, KeyCode.Home) 37 | define(37, KeyCode.LeftArrow) 38 | define(38, KeyCode.UpArrow) 39 | define(39, KeyCode.RightArrow) 40 | define(40, KeyCode.DownArrow) 41 | define(45, KeyCode.Insert) 42 | define(46, KeyCode.Delete) 43 | 44 | define(48, KeyCode.KEY_0) 45 | define(49, KeyCode.KEY_1) 46 | define(50, KeyCode.KEY_2) 47 | define(51, KeyCode.KEY_3) 48 | define(52, KeyCode.KEY_4) 49 | define(53, KeyCode.KEY_5) 50 | define(54, KeyCode.KEY_6) 51 | define(55, KeyCode.KEY_7) 52 | define(56, KeyCode.KEY_8) 53 | define(57, KeyCode.KEY_9) 54 | 55 | define(65, KeyCode.KEY_A) 56 | define(66, KeyCode.KEY_B) 57 | define(67, KeyCode.KEY_C) 58 | define(68, KeyCode.KEY_D) 59 | define(69, KeyCode.KEY_E) 60 | define(70, KeyCode.KEY_F) 61 | define(71, KeyCode.KEY_G) 62 | define(72, KeyCode.KEY_H) 63 | define(73, KeyCode.KEY_I) 64 | define(74, KeyCode.KEY_J) 65 | define(75, KeyCode.KEY_K) 66 | define(76, KeyCode.KEY_L) 67 | define(77, KeyCode.KEY_M) 68 | define(78, KeyCode.KEY_N) 69 | define(79, KeyCode.KEY_O) 70 | define(80, KeyCode.KEY_P) 71 | define(81, KeyCode.KEY_Q) 72 | define(82, KeyCode.KEY_R) 73 | define(83, KeyCode.KEY_S) 74 | define(84, KeyCode.KEY_T) 75 | define(85, KeyCode.KEY_U) 76 | define(86, KeyCode.KEY_V) 77 | define(87, KeyCode.KEY_W) 78 | define(88, KeyCode.KEY_X) 79 | define(89, KeyCode.KEY_Y) 80 | define(90, KeyCode.KEY_Z) 81 | 82 | define(93, KeyCode.ContextMenu) 83 | 84 | define(96, KeyCode.NUMPAD_0) 85 | define(97, KeyCode.NUMPAD_1) 86 | define(98, KeyCode.NUMPAD_2) 87 | define(99, KeyCode.NUMPAD_3) 88 | define(100, KeyCode.NUMPAD_4) 89 | define(101, KeyCode.NUMPAD_5) 90 | define(102, KeyCode.NUMPAD_6) 91 | define(103, KeyCode.NUMPAD_7) 92 | define(104, KeyCode.NUMPAD_8) 93 | define(105, KeyCode.NUMPAD_9) 94 | define(106, KeyCode.NUMPAD_MULTIPLY) 95 | define(107, KeyCode.NUMPAD_ADD) 96 | define(108, KeyCode.NUMPAD_SEPARATOR) 97 | define(109, KeyCode.NUMPAD_SUBTRACT) 98 | define(110, KeyCode.NUMPAD_DECIMAL) 99 | define(111, KeyCode.NUMPAD_DIVIDE) 100 | 101 | define(112, KeyCode.F1) 102 | define(113, KeyCode.F2) 103 | define(114, KeyCode.F3) 104 | define(115, KeyCode.F4) 105 | define(116, KeyCode.F5) 106 | define(117, KeyCode.F6) 107 | define(118, KeyCode.F7) 108 | define(119, KeyCode.F8) 109 | define(120, KeyCode.F9) 110 | define(121, KeyCode.F10) 111 | define(122, KeyCode.F11) 112 | define(123, KeyCode.F12) 113 | define(124, KeyCode.F13) 114 | define(125, KeyCode.F14) 115 | define(126, KeyCode.F15) 116 | define(127, KeyCode.F16) 117 | define(128, KeyCode.F17) 118 | define(129, KeyCode.F18) 119 | define(130, KeyCode.F19) 120 | 121 | define(144, KeyCode.NumLock) 122 | define(145, KeyCode.ScrollLock) 123 | 124 | define(186, KeyCode.US_SEMICOLON) 125 | define(187, KeyCode.US_EQUAL) 126 | define(188, KeyCode.US_COMMA) 127 | define(189, KeyCode.US_MINUS) 128 | define(190, KeyCode.US_DOT) 129 | define(191, KeyCode.US_SLASH) 130 | define(192, KeyCode.US_BACKTICK) 131 | define(193, KeyCode.ABNT_C1) 132 | define(194, KeyCode.ABNT_C2) 133 | define(219, KeyCode.US_OPEN_SQUARE_BRACKET) 134 | define(220, KeyCode.US_BACKSLASH) 135 | define(221, KeyCode.US_CLOSE_SQUARE_BRACKET) 136 | define(222, KeyCode.US_QUOTE) 137 | define(223, KeyCode.OEM_8) 138 | 139 | define(226, KeyCode.OEM_102) 140 | 141 | /** 142 | * https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html 143 | * If an Input Method Editor is processing key input and the event is keydown, return 229. 144 | */ 145 | define(229, KeyCode.KEY_IN_COMPOSITION) 146 | 147 | define(91, KeyCode.Meta) 148 | if (platform.isMacintosh) { 149 | // the two meta keys in the Mac have different key codes (91 and 93) 150 | define(93, KeyCode.Meta) 151 | } else { 152 | define(92, KeyCode.Meta) 153 | } 154 | })() 155 | 156 | function extractKeyCode(e: KeyboardEvent): KeyCode { 157 | if (e.charCode) { 158 | // "keypress" events mostly 159 | const char = String.fromCharCode(e.charCode).toUpperCase() 160 | return KeyCodeUtils.fromString(char) 161 | } 162 | return KEY_CODE_MAP[e.keyCode] || KeyCode.Unknown 163 | } 164 | 165 | export function getCodeForKeyCode(keyCode: KeyCode): number { 166 | return INVERSE_KEY_CODE_MAP[keyCode] 167 | } 168 | 169 | export interface IKeyboardEvent { 170 | readonly browserEvent: KeyboardEvent; 171 | readonly target: HTMLElement; 172 | 173 | readonly ctrlKey: boolean; 174 | readonly shiftKey: boolean; 175 | readonly altKey: boolean; 176 | readonly metaKey: boolean; 177 | readonly keyCode: KeyCode; 178 | readonly code: string; 179 | 180 | /** 181 | * @internal 182 | */ 183 | toKeybinding(): SimpleKeybinding; 184 | equals(keybinding: number): boolean; 185 | 186 | preventDefault(): void; 187 | stopPropagation(): void; 188 | } 189 | 190 | const ctrlKeyMod = (platform.isMacintosh ? KeyMod.WinCtrl : KeyMod.CtrlCmd) 191 | const altKeyMod = KeyMod.Alt 192 | const shiftKeyMod = KeyMod.Shift 193 | const metaKeyMod = (platform.isMacintosh ? KeyMod.CtrlCmd : KeyMod.WinCtrl) 194 | 195 | export class StandardKeyboardEvent implements IKeyboardEvent { 196 | public readonly browserEvent: KeyboardEvent 197 | public readonly target: HTMLElement 198 | 199 | public readonly ctrlKey: boolean 200 | public readonly shiftKey: boolean 201 | public readonly altKey: boolean 202 | public readonly metaKey: boolean 203 | public readonly keyCode: KeyCode 204 | public readonly code: string 205 | 206 | private _asKeybinding: number 207 | private _asRuntimeKeybinding: SimpleKeybinding 208 | 209 | constructor(source: KeyboardEvent) { 210 | const e = source 211 | 212 | this.browserEvent = e 213 | this.target = e.target 214 | 215 | this.ctrlKey = e.ctrlKey 216 | this.shiftKey = e.shiftKey 217 | this.altKey = e.altKey 218 | this.metaKey = e.metaKey 219 | this.keyCode = extractKeyCode(e) 220 | this.code = e.code 221 | 222 | // console.info(e.type + ": keyCode: " + e.keyCode + ", which: " + e.which + ", charCode: " + e.charCode + ", detail: " + e.detail + " ====> " + this.keyCode + ' -- ' + KeyCode[this.keyCode]); 223 | 224 | this.ctrlKey = this.ctrlKey || this.keyCode === KeyCode.Ctrl 225 | this.altKey = this.altKey || this.keyCode === KeyCode.Alt 226 | this.shiftKey = this.shiftKey || this.keyCode === KeyCode.Shift 227 | this.metaKey = this.metaKey || this.keyCode === KeyCode.Meta 228 | 229 | this._asKeybinding = this._computeKeybinding() 230 | this._asRuntimeKeybinding = this._computeRuntimeKeybinding() 231 | } 232 | 233 | public preventDefault(): void { 234 | if (this.browserEvent && this.browserEvent.preventDefault) { 235 | this.browserEvent.preventDefault() 236 | } 237 | } 238 | 239 | public stopPropagation(): void { 240 | if (this.browserEvent && this.browserEvent.stopPropagation) { 241 | this.browserEvent.stopPropagation() 242 | } 243 | } 244 | 245 | public toKeybinding(): SimpleKeybinding { 246 | return this._asRuntimeKeybinding 247 | } 248 | 249 | public equals(other: number): boolean { 250 | return this._asKeybinding === other 251 | } 252 | 253 | private _computeKeybinding(): number { 254 | let key = KeyCode.Unknown 255 | if (this.keyCode !== KeyCode.Ctrl && this.keyCode !== KeyCode.Shift && this.keyCode !== KeyCode.Alt && this.keyCode !== KeyCode.Meta) { 256 | key = this.keyCode 257 | } 258 | 259 | let result = 0 260 | if (this.ctrlKey) { 261 | result |= ctrlKeyMod 262 | } 263 | if (this.altKey) { 264 | result |= altKeyMod 265 | } 266 | if (this.shiftKey) { 267 | result |= shiftKeyMod 268 | } 269 | if (this.metaKey) { 270 | result |= metaKeyMod 271 | } 272 | result |= key 273 | 274 | return result 275 | } 276 | 277 | private _computeRuntimeKeybinding(): SimpleKeybinding { 278 | let key = KeyCode.Unknown 279 | if (this.keyCode !== KeyCode.Ctrl && this.keyCode !== KeyCode.Shift && this.keyCode !== KeyCode.Alt && this.keyCode !== KeyCode.Meta) { 280 | key = this.keyCode 281 | } 282 | return new SimpleKeybinding(this.ctrlKey, this.shiftKey, this.altKey, this.metaKey, key) 283 | } 284 | } -------------------------------------------------------------------------------- /static/theme/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0 !important; 3 | overflow: hidden !important; 4 | } 5 | 6 | /* Titlebar */ 7 | .cet-titlebar { 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | flex-shrink: 0; 12 | flex-wrap: wrap; 13 | position: absolute; 14 | top: 0; 15 | left: 0; 16 | right: 0; 17 | font-size: 13px; 18 | font-family: Arial, Helvetica, sans-serif; 19 | box-sizing: border-box; 20 | padding: 0 16px; 21 | overflow: hidden; 22 | -webkit-user-select: none; 23 | -ms-user-select: none; 24 | user-select: none; 25 | zoom: 1; 26 | width: 100%; 27 | height: 31px; 28 | line-height: 31px; 29 | z-index: 99999; 30 | } 31 | 32 | .cet-titlebar *, 33 | .cet-titlebar *:before, 34 | .cet-titlebar *:after { 35 | box-sizing: border-box; 36 | } 37 | 38 | .cet-titlebar.cet-windows, 39 | .cet-titlebar.cet-linux, 40 | .cet-titlebar.cet-freebsd { 41 | padding: 0; 42 | height: 30px; 43 | line-height: 30px; 44 | justify-content: left; 45 | overflow: visible; 46 | } 47 | 48 | /* Inverted */ 49 | .cet-titlebar.cet-inverted { 50 | flex-direction: row-reverse; 51 | } 52 | 53 | .cet-titlebar.cet-inverted .cet-menubar, 54 | .cet-titlebar.cet-inverted .cet-window-controls { 55 | flex-direction: row-reverse; 56 | margin-left: 20px; 57 | margin-right: 0; 58 | } 59 | 60 | /* First buttons */ 61 | .cet-titlebar.cet-first-buttons .cet-window-controls { 62 | order: -1; 63 | margin: 0 5px 0 0; 64 | } 65 | 66 | .cet-titlebar.cet-inverted .cet-window-controls { 67 | margin: 0 5px 0 0; 68 | } 69 | 70 | /* Shadow */ 71 | .cet-titlebar.cet-shadow { 72 | box-shadow: 0 2px 1px -1px rgba(0, 0, 0, 0.2), 0 1px 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.12); 73 | } 74 | 75 | /* Drag region */ 76 | .cet-drag-region { 77 | top: 0; 78 | left: 0; 79 | display: block; 80 | position: absolute; 81 | width: 100%; 82 | height: 100%; 83 | z-index: -1; 84 | -webkit-app-region: drag; 85 | } 86 | 87 | /* Icon */ 88 | .cet-icon { 89 | display: flex; 90 | align-items: center; 91 | justify-content: center; 92 | width: 34px; 93 | height: 30px; 94 | z-index: 99; 95 | overflow: hidden; 96 | } 97 | 98 | /* Title */ 99 | .cet-title { 100 | flex: 0 1 auto; 101 | font-size: 12px; 102 | overflow: hidden; 103 | white-space: nowrap; 104 | text-overflow: ellipsis; 105 | zoom: 1; 106 | } 107 | 108 | /* Title alignment */ 109 | .cet-title.cet-title-left { 110 | margin-left: 8px; 111 | margin-right: auto; 112 | } 113 | 114 | .cet-title.cet-title-right { 115 | margin-left: auto; 116 | margin-right: 8px; 117 | } 118 | 119 | .cet-title.cet-title-center { 120 | position: absolute; 121 | left: 50%; 122 | transform: translateX(-50%); 123 | } 124 | 125 | .cet-title.cet-bigsur { 126 | font-size: 13px; 127 | font-weight: 600; 128 | } 129 | 130 | /* Window controls */ 131 | .cet-window-controls { 132 | display: flex; 133 | flex-grow: 0; 134 | flex-shrink: 0; 135 | text-align: center; 136 | position: relative; 137 | z-index: 99; 138 | -webkit-app-region: no-drag; 139 | height: 30px; 140 | font-family: initial !important; 141 | margin-left: auto; 142 | } 143 | 144 | .cet-control-icon { 145 | width: 2.85rem; 146 | } 147 | 148 | .cet-control-icon:not(.inactive):hover { 149 | background-color: rgb(255 255 255 / 12%); 150 | } 151 | 152 | .light .cet-control-icon:not(.inactive):hover { 153 | background-color: rgb(0 0 0 / 12%); 154 | } 155 | 156 | .cet-control-icon.inactive svg { 157 | opacity: 0.4; 158 | } 159 | 160 | .cet-control-icon svg { 161 | width: 10px; 162 | height: -webkit-fill-available; 163 | fill: #fff; 164 | display: initial !important; 165 | vertical-align: unset !important; 166 | } 167 | 168 | .cet-titlebar.light .cet-control-icon svg { 169 | fill: #222222 !important; 170 | } 171 | 172 | .cet-control-close:not(.inactive):hover { 173 | background-color: rgb(232 17 35 / 90%) !important; 174 | } 175 | 176 | .cet-control-close:not(.inactive):hover svg { 177 | fill: #fff !important; 178 | } 179 | 180 | /* Resizer */ 181 | .cet-resizer { 182 | -webkit-app-region: no-drag; 183 | position: absolute; 184 | } 185 | 186 | .cet-resizer.left { 187 | top: 0; 188 | left: 0; 189 | width: 6px; 190 | height: 100%; 191 | } 192 | 193 | .cet-resizer.top { 194 | top: 0; 195 | width: 100%; 196 | height: 6px; 197 | } 198 | 199 | /* Container */ 200 | .cet-container { 201 | position: absolute; 202 | left: 0; 203 | right: 0; 204 | bottom: 0; 205 | overflow: auto; 206 | z-index: 1; 207 | } 208 | 209 | /* MenuBar */ 210 | .cet-menubar { 211 | display: flex; 212 | flex-shrink: 1; 213 | box-sizing: border-box; 214 | overflow: hidden; 215 | flex-wrap: wrap; 216 | margin-right: 20px; 217 | } 218 | 219 | .cet-menubar.bottom { 220 | order: 1; 221 | width: 100%; 222 | padding: 0 5px 5px; 223 | margin-right: 0; 224 | } 225 | 226 | .cet-menubar.bottom .cet-menubar-menu-button { 227 | border-radius: 4px; 228 | } 229 | 230 | .cet-menubar.bottom .cet-menubar-menu-button .cet-menubar-menu-title { 231 | line-height: 26px; 232 | } 233 | 234 | .cet-menubar .cet-menubar-menu-button { 235 | box-sizing: border-box; 236 | padding: 0px 8px; 237 | height: 100%; 238 | cursor: default; 239 | zoom: 1; 240 | white-space: nowrap; 241 | -webkit-app-region: no-drag; 242 | outline: 0; 243 | } 244 | 245 | .cet-menubar .cet-menubar-menu-button .cet-menubar-menu-title { 246 | font-size: 12px; 247 | } 248 | 249 | .cet-menubar .cet-menubar-menu-button.disabled { 250 | opacity: 0.4; 251 | } 252 | 253 | .cet-menubar .cet-menubar-menu-button:not(.disabled):hover, 254 | .cet-menubar .cet-menubar-menu-button:not(.disabled).open { 255 | background-color: rgb(255 255 255 / 12%); 256 | } 257 | 258 | .cet-titlebar.light .cet-menubar .cet-menubar-menu-button:not(.disabled):hover, 259 | .cet-titlebar.light .cet-menubar .cet-menubar-menu-button:not(.disabled).open { 260 | background-color: rgb(0 0 0 / 12%); 261 | } 262 | 263 | .cet-menubar-menu-container { 264 | position: absolute; 265 | display: block; 266 | left: 0px; 267 | opacity: 1; 268 | outline: 0; 269 | border: none; 270 | text-align: left; 271 | margin: 0 auto; 272 | margin-left: 0; 273 | font-size: inherit; 274 | overflow-x: visible; 275 | overflow-y: visible; 276 | -webkit-overflow-scrolling: touch; 277 | justify-content: flex-end; 278 | white-space: nowrap; 279 | border-radius: 7px; 280 | backdrop-filter: blur(10px); 281 | box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12); 282 | z-index: 99999; 283 | } 284 | 285 | .cet-menubar-menu-container::-webkit-scrollbar { 286 | width: 8px; 287 | height: 4px; 288 | cursor: pointer; 289 | background-color: rgba(0, 0, 0, 0); 290 | } 291 | 292 | .cet-menubar-menu-container::-webkit-scrollbar-track { 293 | border: none; 294 | background-color: rgba(0, 0, 0, 0); 295 | } 296 | 297 | .cet-menubar-menu-container::-webkit-scrollbar-thumb { 298 | border-radius: 10px; 299 | background-color: rgba(110, 110, 110, 0.2); 300 | } 301 | 302 | .cet-menubar-menu-container:focus { 303 | outline: 0; 304 | } 305 | 306 | .cet-menubar-menu-container .cet-action-item { 307 | padding: 0; 308 | margin: 0; 309 | transform: none; 310 | display: -ms-flexbox; 311 | display: flex; 312 | outline: none; 313 | } 314 | 315 | .cet-menubar-menu-container .cet-action-item.active { 316 | transform: none; 317 | } 318 | 319 | .cet-menubar-menu-container .cet-action-item.disabled .cet-action-menu-item { 320 | opacity: 0.4; 321 | } 322 | 323 | .cet-menubar-menu-container .cet-action-item .cet-submenu { 324 | position: absolute; 325 | } 326 | 327 | .cet-menubar-menu-container .cet-action-menu-item { 328 | -ms-flex: 1 1 auto; 329 | flex: 1 1 auto; 330 | display: -ms-flexbox; 331 | display: flex; 332 | height: 2.231em; 333 | margin: 4px 3px; 334 | align-items: center; 335 | position: relative; 336 | border-radius: 4px; 337 | text-decoration: none; 338 | } 339 | 340 | .cet-menubar-menu-container .cet-action-label { 341 | -ms-flex: 1 1 auto; 342 | flex: 1 1 auto; 343 | text-decoration: none; 344 | padding: 0 1em; 345 | background: none; 346 | font-size: 12px; 347 | line-height: 1; 348 | } 349 | 350 | .cet-menubar-menu-container .cet-action-label:not(.separator) { 351 | display: inline-block; 352 | -webkit-box-sizing: border-box; 353 | -o-box-sizing: border-box; 354 | -moz-box-sizing: border-box; 355 | -ms-box-sizing: border-box; 356 | box-sizing: border-box; 357 | margin: 0; 358 | padding: 0 2em 0 0.8em; 359 | } 360 | 361 | .cet-menubar-menu-container .cet-action-label.separator { 362 | opacity: 0.1; 363 | font-size: inherit; 364 | width: 100%; 365 | border-bottom: 1px solid transparent; 366 | } 367 | 368 | .cet-menubar-menu-container .cet-action-label.separator.text { 369 | padding: 0.7em 1em 0.1em 1em; 370 | font-weight: bold; 371 | opacity: 1; 372 | } 373 | 374 | .cet-menubar-menu-container .cet-action-label:hover { 375 | color: inherit; 376 | } 377 | 378 | .cet-menubar-menu-container .keybinding, 379 | .cet-menubar-menu-container .cet-submenu-indicator { 380 | display: inline-block; 381 | -ms-flex: 2 1 auto; 382 | flex: 2 1 auto; 383 | padding: 0 2em 0 1em; 384 | text-align: right; 385 | font-size: 11px; 386 | line-height: 1; 387 | } 388 | 389 | .cet-menubar-menu-container .cet-submenu-indicator { 390 | position: absolute; 391 | right: 4px; 392 | height: 12px; 393 | width: 12px; 394 | padding: 0; 395 | } 396 | 397 | .cet-menubar-menu-container .cet-submenu-indicator img, 398 | .cet-menubar-menu-container .cet-menu-item-icon .icon, 399 | .cet-menubar-menu-container .cet-submenu-indicator svg, 400 | .cet-menubar-menu-container .cet-menu-item-icon svg { 401 | display: inherit; 402 | width: 100%; 403 | height: 100%; 404 | } 405 | 406 | .cet-menubar-menu-container .cet-action-menu-item.checked>.cet-menu-item-icon.checkbox { 407 | visibility: visible; 408 | } 409 | 410 | .cet-menubar-menu-container .cet-menu-item-icon { 411 | width: 14px; 412 | height: 14px; 413 | margin: 0 0 0 12px; 414 | } 415 | 416 | .cet-menubar-menu-container .cet-menu-item-icon.checkbox { 417 | visibility: hidden; 418 | } -------------------------------------------------------------------------------- /src/base/browser/touch.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import * as DomUtils from 'base/common/dom'; 7 | import * as arrays from 'base/common/arrays'; 8 | import { memoize } from 'base/common/decorators'; 9 | import { Disposable, IDisposable, toDisposable } from 'base/common/lifecycle'; 10 | import { LinkedList } from 'base/common/linkedList'; 11 | 12 | export namespace EventType { 13 | export const Tap = '-monaco-gesturetap'; 14 | export const Change = '-monaco-gesturechange'; 15 | export const Start = '-monaco-gesturestart'; 16 | export const End = '-monaco-gesturesend'; 17 | export const Contextmenu = '-monaco-gesturecontextmenu'; 18 | } 19 | 20 | interface TouchData { 21 | id: number; 22 | initialTarget: EventTarget; 23 | initialTimeStamp: number; 24 | initialPageX: number; 25 | initialPageY: number; 26 | rollingTimestamps: number[]; 27 | rollingPageX: number[]; 28 | rollingPageY: number[]; 29 | } 30 | 31 | export interface GestureEvent extends MouseEvent { 32 | initialTarget: EventTarget | undefined; 33 | translationX: number; 34 | translationY: number; 35 | pageX: number; 36 | pageY: number; 37 | tapCount: number; 38 | } 39 | 40 | interface Touch { 41 | identifier: number; 42 | screenX: number; 43 | screenY: number; 44 | clientX: number; 45 | clientY: number; 46 | pageX: number; 47 | pageY: number; 48 | radiusX: number; 49 | radiusY: number; 50 | rotationAngle: number; 51 | force: number; 52 | target: Element; 53 | } 54 | 55 | interface TouchList { 56 | [i: number]: Touch; 57 | length: number; 58 | item(index: number): Touch; 59 | identifiedTouch(id: number): Touch; 60 | } 61 | 62 | interface TouchEvent extends Event { 63 | touches: TouchList; 64 | targetTouches: TouchList; 65 | changedTouches: TouchList; 66 | } 67 | 68 | export class Gesture extends Disposable { 69 | 70 | private static readonly SCROLL_FRICTION = -0.005; 71 | private static INSTANCE: Gesture; 72 | private static readonly HOLD_DELAY = 700; 73 | 74 | private dispatched = false; 75 | private readonly targets = new LinkedList(); 76 | private readonly ignoreTargets = new LinkedList(); 77 | private handle: IDisposable | null; 78 | 79 | private readonly activeTouches: { [id: number]: TouchData }; 80 | 81 | private _lastSetTapCountTime: number; 82 | 83 | private static readonly CLEAR_TAP_COUNT_TIME = 400; // ms 84 | 85 | 86 | private constructor() { 87 | super(); 88 | 89 | this.activeTouches = {}; 90 | this.handle = null; 91 | this._lastSetTapCountTime = 0; 92 | this._register(DomUtils.addDisposableListener(document, 'touchstart', (e: TouchEvent) => this.onTouchStart(e), { passive: false })); 93 | this._register(DomUtils.addDisposableListener(document, 'touchend', (e: TouchEvent) => this.onTouchEnd(e))); 94 | this._register(DomUtils.addDisposableListener(document, 'touchmove', (e: TouchEvent) => this.onTouchMove(e), { passive: false })); 95 | } 96 | 97 | public static addTarget(element: HTMLElement): IDisposable { 98 | if (!Gesture.isTouchDevice()) { 99 | return Disposable.None; 100 | } 101 | if (!Gesture.INSTANCE) { 102 | Gesture.INSTANCE = new Gesture(); 103 | } 104 | 105 | const remove = Gesture.INSTANCE.targets.push(element); 106 | return toDisposable(remove); 107 | } 108 | 109 | public static ignoreTarget(element: HTMLElement): IDisposable { 110 | if (!Gesture.isTouchDevice()) { 111 | return Disposable.None; 112 | } 113 | if (!Gesture.INSTANCE) { 114 | Gesture.INSTANCE = new Gesture(); 115 | } 116 | 117 | const remove = Gesture.INSTANCE.ignoreTargets.push(element); 118 | return toDisposable(remove); 119 | } 120 | 121 | static isTouchDevice(): boolean { 122 | // `'ontouchstart' in window` always evaluates to true with typescript's modern typings. This causes `window` to be 123 | // `never` later in `window.navigator`. That's why we need the explicit `window as Window` cast 124 | return 'ontouchstart' in window || navigator.maxTouchPoints > 0; 125 | } 126 | 127 | public override dispose(): void { 128 | if (this.handle) { 129 | this.handle.dispose(); 130 | this.handle = null; 131 | } 132 | 133 | super.dispose(); 134 | } 135 | 136 | private onTouchStart(e: TouchEvent): void { 137 | const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based. 138 | 139 | if (this.handle) { 140 | this.handle.dispose(); 141 | this.handle = null; 142 | } 143 | 144 | for (let i = 0, len = e.targetTouches.length; i < len; i++) { 145 | const touch = e.targetTouches.item(i); 146 | 147 | this.activeTouches[touch.identifier] = { 148 | id: touch.identifier, 149 | initialTarget: touch.target, 150 | initialTimeStamp: timestamp, 151 | initialPageX: touch.pageX, 152 | initialPageY: touch.pageY, 153 | rollingTimestamps: [timestamp], 154 | rollingPageX: [touch.pageX], 155 | rollingPageY: [touch.pageY] 156 | }; 157 | 158 | const evt = this.newGestureEvent(EventType.Start, touch.target); 159 | evt.pageX = touch.pageX; 160 | evt.pageY = touch.pageY; 161 | this.dispatchEvent(evt); 162 | } 163 | 164 | if (this.dispatched) { 165 | e.preventDefault(); 166 | e.stopPropagation(); 167 | this.dispatched = false; 168 | } 169 | } 170 | 171 | private onTouchEnd(e: TouchEvent): void { 172 | const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based. 173 | 174 | const activeTouchCount = Object.keys(this.activeTouches).length; 175 | 176 | for (let i = 0, len = e.changedTouches.length; i < len; i++) { 177 | 178 | const touch = e.changedTouches.item(i); 179 | 180 | if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) { 181 | console.warn('move of an UNKNOWN touch', touch); 182 | continue; 183 | } 184 | 185 | const data = this.activeTouches[touch.identifier], 186 | holdTime = Date.now() - data.initialTimeStamp; 187 | 188 | if (holdTime < Gesture.HOLD_DELAY 189 | && Math.abs(data.initialPageX - arrays.tail(data.rollingPageX)) < 30 190 | && Math.abs(data.initialPageY - arrays.tail(data.rollingPageY)) < 30) { 191 | 192 | const evt = this.newGestureEvent(EventType.Tap, data.initialTarget); 193 | evt.pageX = arrays.tail(data.rollingPageX); 194 | evt.pageY = arrays.tail(data.rollingPageY); 195 | this.dispatchEvent(evt); 196 | 197 | } else if (holdTime >= Gesture.HOLD_DELAY 198 | && Math.abs(data.initialPageX - arrays.tail(data.rollingPageX)) < 30 199 | && Math.abs(data.initialPageY - arrays.tail(data.rollingPageY)) < 30) { 200 | 201 | const evt = this.newGestureEvent(EventType.Contextmenu, data.initialTarget); 202 | evt.pageX = arrays.tail(data.rollingPageX); 203 | evt.pageY = arrays.tail(data.rollingPageY); 204 | this.dispatchEvent(evt); 205 | 206 | } else if (activeTouchCount === 1) { 207 | const finalX = arrays.tail(data.rollingPageX); 208 | const finalY = arrays.tail(data.rollingPageY); 209 | 210 | const deltaT = arrays.tail(data.rollingTimestamps) - data.rollingTimestamps[0]; 211 | const deltaX = finalX - data.rollingPageX[0]; 212 | const deltaY = finalY - data.rollingPageY[0]; 213 | } 214 | 215 | 216 | this.dispatchEvent(this.newGestureEvent(EventType.End, data.initialTarget)); 217 | // forget about this touch 218 | delete this.activeTouches[touch.identifier]; 219 | } 220 | 221 | if (this.dispatched) { 222 | e.preventDefault(); 223 | e.stopPropagation(); 224 | this.dispatched = false; 225 | } 226 | } 227 | 228 | private newGestureEvent(type: string, initialTarget?: EventTarget): GestureEvent { 229 | const event = document.createEvent('CustomEvent') as unknown as GestureEvent; 230 | event.initEvent(type, false, true); 231 | event.initialTarget = initialTarget; 232 | event.tapCount = 0; 233 | return event; 234 | } 235 | 236 | private dispatchEvent(event: GestureEvent): void { 237 | if (event.type === EventType.Tap) { 238 | const currentTime = (new Date()).getTime(); 239 | let setTapCount = 0; 240 | if (currentTime - this._lastSetTapCountTime > Gesture.CLEAR_TAP_COUNT_TIME) { 241 | setTapCount = 1; 242 | } else { 243 | setTapCount = 2; 244 | } 245 | 246 | this._lastSetTapCountTime = currentTime; 247 | event.tapCount = setTapCount; 248 | } else if (event.type === EventType.Change || event.type === EventType.Contextmenu) { 249 | // tap is canceled by scrolling or context menu 250 | this._lastSetTapCountTime = 0; 251 | } 252 | } 253 | 254 | private inertia(dispatchTo: readonly EventTarget[], t1: number, vX: number, dirX: number, x: number, vY: number, dirY: number, y: number): void { 255 | this.handle = DomUtils.scheduleAtNextAnimationFrame(() => { 256 | const now = Date.now(); 257 | 258 | // velocity: old speed + accel_over_time 259 | const deltaT = now - t1; 260 | let delta_pos_x = 0, delta_pos_y = 0; 261 | let stopped = true; 262 | 263 | vX += Gesture.SCROLL_FRICTION * deltaT; 264 | vY += Gesture.SCROLL_FRICTION * deltaT; 265 | 266 | if (vX > 0) { 267 | stopped = false; 268 | delta_pos_x = dirX * vX * deltaT; 269 | } 270 | 271 | if (vY > 0) { 272 | stopped = false; 273 | delta_pos_y = dirY * vY * deltaT; 274 | } 275 | 276 | // dispatch translation event 277 | const evt = this.newGestureEvent(EventType.Change); 278 | evt.translationX = delta_pos_x; 279 | evt.translationY = delta_pos_y; 280 | dispatchTo.forEach(d => d.dispatchEvent(evt)); 281 | 282 | if (!stopped) { 283 | this.inertia(dispatchTo, now, vX, dirX, x + delta_pos_x, vY, dirY, y + delta_pos_y); 284 | } 285 | }); 286 | } 287 | 288 | private onTouchMove(e: TouchEvent): void { 289 | const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based. 290 | 291 | for (let i = 0, len = e.changedTouches.length; i < len; i++) { 292 | 293 | const touch = e.changedTouches.item(i); 294 | 295 | if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) { 296 | console.warn('end of an UNKNOWN touch', touch); 297 | continue; 298 | } 299 | 300 | const data = this.activeTouches[touch.identifier]; 301 | 302 | const evt = this.newGestureEvent(EventType.Change, data.initialTarget); 303 | evt.translationX = touch.pageX - arrays.tail(data.rollingPageX); 304 | evt.translationY = touch.pageY - arrays.tail(data.rollingPageY); 305 | evt.pageX = touch.pageX; 306 | evt.pageY = touch.pageY; 307 | this.dispatchEvent(evt); 308 | 309 | // only keep a few data points, to average the final speed 310 | if (data.rollingPageX.length > 3) { 311 | data.rollingPageX.shift(); 312 | data.rollingPageY.shift(); 313 | data.rollingTimestamps.shift(); 314 | } 315 | 316 | data.rollingPageX.push(touch.pageX); 317 | data.rollingPageY.push(touch.pageY); 318 | data.rollingTimestamps.push(timestamp); 319 | } 320 | 321 | if (this.dispatched) { 322 | e.preventDefault(); 323 | e.stopPropagation(); 324 | this.dispatched = false; 325 | } 326 | } 327 | } -------------------------------------------------------------------------------- /src/menubar/menu/item.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) AlexTorresDev. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { MenuItem, ipcRenderer, nativeImage } from 'electron' 7 | import { Color } from 'base/common/color' 8 | import { $, EventHelper, EventLike, EventType, addClass, addDisposableListener, append, hasClass, removeClass, removeNode } from 'base/common/dom' 9 | import { KeyCode, KeyCodeUtils } from 'base/common/keyCodes' 10 | import { Disposable } from 'base/common/lifecycle' 11 | import { MENU_ESCAPED_MNEMONIC_REGEX, MENU_MNEMONIC_REGEX, applyFill, parseAccelerator, cleanMnemonic, loadWindowIcons } from 'consts' 12 | import { MenuBarOptions } from '../menubar-options' 13 | import { IMenuOptions } from './index' 14 | import * as strings from 'base/common/strings' 15 | import { IMenuIcons } from 'menubar' 16 | 17 | export interface IMenuStyle { 18 | foregroundColor?: Color 19 | backgroundColor?: Color 20 | selectionForegroundColor?: Color 21 | selectionBackgroundColor?: Color 22 | separatorColor?: Color 23 | svgColor?: Color 24 | } 25 | 26 | export interface IMenuItem { 27 | render(element: HTMLElement): void 28 | updateStyle(style: IMenuStyle): void 29 | onClick(event: EventLike): void 30 | dispose(): void 31 | isEnabled(): boolean 32 | isSeparator(): boolean 33 | focus(): void 34 | blur(): void 35 | } 36 | 37 | export class CETMenuItem extends Disposable implements IMenuItem { 38 | private _mnemonic?: KeyCode 39 | private _currentElement?: HTMLElement 40 | 41 | private labelElement?: HTMLElement 42 | private iconElement?: HTMLElement 43 | 44 | protected itemElement?: HTMLElement 45 | protected menuStyle?: IMenuStyle 46 | 47 | private radioGroup?: { start: number, end: number } // used only if item.type === "radio" 48 | 49 | constructor(private _item: MenuItem, private menuIcons: IMenuIcons, private parentOptions: MenuBarOptions, private options: IMenuOptions, private menuItems?: IMenuItem[], private closeSubMenu = () => { }) { 50 | super() 51 | 52 | // Set mnemonic 53 | if (this._item.label && options.enableMnemonics) { 54 | const label = this._item.label 55 | if (label) { 56 | const matches = MENU_MNEMONIC_REGEX.exec(label) 57 | if (matches) { 58 | this._mnemonic = KeyCodeUtils.fromString((!!matches[1] ? matches[1] : matches[2]).toLocaleUpperCase()) 59 | } 60 | } 61 | } 62 | } 63 | 64 | render(el: HTMLElement): void { 65 | this._currentElement = el 66 | 67 | this._register(addDisposableListener(this.element!, EventType.MOUSE_DOWN, e => { 68 | if (this.item.enabled && e.button === 0 && this.element) { 69 | addClass(this.element, 'active') 70 | } 71 | })) 72 | 73 | this._register(addDisposableListener(this.element!, EventType.CLICK, e => { 74 | if (this.item.enabled) { 75 | this.onClick(e) 76 | } 77 | })) 78 | 79 | this._register(addDisposableListener(this.element!, EventType.DBLCLICK, e => { 80 | EventHelper.stop(e, true) 81 | })); 82 | 83 | 84 | [EventType.MOUSE_UP, EventType.MOUSE_OUT].forEach(event => { 85 | this._register(addDisposableListener(this.element!, event, e => { 86 | EventHelper.stop(e) 87 | removeClass(this.element!, 'active') 88 | })) 89 | }) 90 | 91 | this.itemElement = append(this.element!, $('a.cet-action-menu-item')) 92 | 93 | if (this.mnemonic) { 94 | this.itemElement.setAttribute('aria-keyshortcuts', `${this.mnemonic}`) 95 | } 96 | 97 | this.iconElement = append(this.itemElement, $('span.cet-menu-item-icon')) 98 | this.iconElement.setAttribute('role', 'none') 99 | 100 | this.labelElement = append(this.itemElement, $('span.cet-action-label')) 101 | 102 | this.updateLabel() 103 | this.setAccelerator() 104 | this.updateIcon() 105 | this.updateTooltip() 106 | this.updateEnabled() 107 | this.updateChecked() 108 | this.updateVisibility() 109 | } 110 | 111 | onClick(event: EventLike) { 112 | EventHelper.stop(event, true) 113 | ipcRenderer.send('menu-event', this.item.commandId) 114 | 115 | if (this.item.type === 'checkbox') { 116 | this.item.checked = !this.item.checked 117 | this.updateChecked() 118 | } else if (this.item.type === 'radio') { 119 | this.updateRadioGroup() 120 | } 121 | 122 | this.closeSubMenu() 123 | } 124 | 125 | protected applyStyle(): void { 126 | if (!this.menuStyle) { 127 | return 128 | } 129 | 130 | const isSelected = this.element && hasClass(this.element, 'focused') 131 | const fgColor = isSelected && this.menuStyle.selectionForegroundColor ? this.menuStyle.selectionForegroundColor : this.menuStyle.foregroundColor 132 | const bgColor = isSelected && this.menuStyle.selectionBackgroundColor ? this.menuStyle.selectionBackgroundColor : null 133 | 134 | if (this.itemElement) { 135 | this.itemElement.style.color = fgColor ? fgColor.toString() : '' 136 | this.itemElement.style.backgroundColor = bgColor ? bgColor.toString() : '' 137 | 138 | if (this.iconElement) { 139 | if (this.iconElement.firstElementChild?.className === 'icon') { 140 | applyFill(this.iconElement.firstElementChild as HTMLElement, this.parentOptions?.svgColor, fgColor, false) 141 | } else { 142 | applyFill(this.iconElement, this.parentOptions?.svgColor, fgColor) 143 | } 144 | } 145 | } 146 | } 147 | 148 | updateStyle(style: IMenuStyle): void { 149 | this.menuStyle = style 150 | this.applyStyle() 151 | } 152 | 153 | focus(): void { 154 | if (this.element) { 155 | this.element.focus() 156 | addClass(this.element, 'focused') 157 | } 158 | 159 | this.applyStyle() 160 | } 161 | 162 | blur(): void { 163 | if (this.element) { 164 | this.element.blur() 165 | removeClass(this.element, 'focused') 166 | } 167 | 168 | this.applyStyle() 169 | } 170 | 171 | setAccelerator(): void { 172 | let accelerator = null 173 | 174 | if (this.item.role) { 175 | switch (this.item.role.toLocaleLowerCase()) { 176 | case 'undo': 177 | accelerator = 'CtrlOrCmd+Z' 178 | break 179 | case 'redo': 180 | accelerator = 'CtrlOrCmd+Y' 181 | break 182 | case 'cut': 183 | accelerator = 'CtrlOrCmd+X' 184 | break 185 | case 'copy': 186 | accelerator = 'CtrlOrCmd+C' 187 | break 188 | case 'paste': 189 | accelerator = 'CtrlOrCmd+V' 190 | break 191 | case 'selectall': 192 | accelerator = 'CtrlOrCmd+A' 193 | break 194 | case 'minimize': 195 | accelerator = 'CtrlOrCmd+M' 196 | break 197 | case 'close': 198 | accelerator = 'CtrlOrCmd+W' 199 | break 200 | case 'reload': 201 | accelerator = 'CtrlOrCmd+R' 202 | break 203 | case 'forcereload': 204 | accelerator = 'CtrlOrCmd+Shift+R' 205 | break 206 | case 'toggledevtools': 207 | accelerator = 'CtrlOrCmd+Shift+I' 208 | break 209 | case 'togglefullscreen': 210 | accelerator = 'F11' 211 | break 212 | case 'resetzoom': 213 | accelerator = 'CtrlOrCmd+0' 214 | break 215 | case 'zoomin': 216 | accelerator = 'CtrlOrCmd++' 217 | break 218 | case 'zoomout': 219 | accelerator = 'CtrlOrCmd+-' 220 | break 221 | } 222 | } 223 | 224 | if (this.item.label && this.item.accelerator) { 225 | accelerator = this.item.accelerator 226 | } 227 | 228 | if (this.itemElement && accelerator !== null) { 229 | append(this.itemElement, $('span.keybinding')).textContent = parseAccelerator(accelerator) 230 | } 231 | } 232 | 233 | updateLabel(): void { 234 | const label = this.item.label || '' 235 | const cleanMenuLabel = cleanMnemonic(label) 236 | 237 | // Update the button label to reflect mnemonics 238 | 239 | if (this.options.enableMnemonics) { 240 | const cleanLabel = strings.escape(label) 241 | 242 | // This is global so reset it 243 | MENU_ESCAPED_MNEMONIC_REGEX.lastIndex = 0 244 | let escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(cleanLabel) 245 | 246 | // We can't use negative lookbehind so we match our negative and skip 247 | while (escMatch && escMatch[1]) { 248 | escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(cleanLabel) 249 | } 250 | 251 | const replaceDoubleEscapes = (str: string) => str.replace(/&&/g, '&') 252 | 253 | if (escMatch) { 254 | this.labelElement!.innerText = '' 255 | this.labelElement!.append( 256 | strings.ltrim(replaceDoubleEscapes(cleanLabel.substring(0, escMatch.index)), ' '), 257 | $('mnemonic', { 'aria-hidden': 'true' }, escMatch[3]), 258 | strings.rtrim(replaceDoubleEscapes(cleanLabel.substring(escMatch.index + escMatch[0].length)), ' ') 259 | ) 260 | } else { 261 | this.labelElement!.innerText = replaceDoubleEscapes(cleanLabel).trim() 262 | } 263 | } else { 264 | this.labelElement!.innerText = cleanMenuLabel.replace(/&&/g, '&') 265 | } 266 | 267 | const mnemonicMatches = MENU_MNEMONIC_REGEX.exec(label) 268 | 269 | // Register mnemonics 270 | if (mnemonicMatches) { 271 | const mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[3] 272 | 273 | if (this.options.enableMnemonics) { 274 | this.itemElement?.setAttribute('aria-keyshortcuts', 'Alt+' + mnemonic.toLocaleLowerCase()) 275 | } else { 276 | this.itemElement?.removeAttribute('aria-keyshortcuts') 277 | } 278 | } 279 | } 280 | 281 | updateIcon(): void { 282 | if (this.item.icon) { 283 | const icon = this.item.icon 284 | 285 | if (this.iconElement && icon) { 286 | const iconE = append(this.iconElement, $('.icon')) 287 | let iconData: string | undefined 288 | 289 | if (typeof this.item.icon !== 'string') { 290 | iconData = ipcRenderer.sendSync('menu-icon', this.item.commandId) 291 | } else { 292 | const iconPath = this.item.icon 293 | iconData = nativeImage.createFromPath(iconPath).toDataURL() 294 | } 295 | 296 | if (iconData) iconE.style.webkitMaskBoxImage = `url(${iconData})` 297 | } 298 | } else if (this.iconElement && this.item.type === 'checkbox') { 299 | addClass(this.iconElement, 'checkbox') 300 | this.iconElement.innerHTML = this.menuIcons.checkbox 301 | } else if (this.item.type === 'radio') { 302 | addClass(this.iconElement!, 'radio') 303 | this.iconElement!.innerHTML = this.item.checked ? this.menuIcons.radioChecked : this.menuIcons.radioUnchecked 304 | } 305 | 306 | applyFill(this.iconElement, this.parentOptions?.svgColor, this.menuStyle?.foregroundColor) 307 | } 308 | 309 | updateTooltip(): void { 310 | let title: string | null = null 311 | 312 | if (this.item.sublabel) { 313 | title = this.item.sublabel 314 | } else if (!this.item.label && this.item.label && this.item.icon) { 315 | title = this.item.label 316 | 317 | if (this.item.accelerator) { 318 | title = parseAccelerator(this.item.accelerator) 319 | } 320 | } 321 | 322 | if (this.itemElement && title) { 323 | this.itemElement.title = title 324 | } 325 | } 326 | 327 | updateEnabled(): void { 328 | if (this.element) { 329 | if (this.item.enabled && this.item.type !== 'separator') { 330 | removeClass(this.element, 'disabled') 331 | this.element.tabIndex = 0 332 | } else { 333 | addClass(this.element, 'disabled') 334 | } 335 | } 336 | } 337 | 338 | updateVisibility(): void { 339 | if (this.item.visible === false && this.itemElement) { 340 | this.itemElement.remove() 341 | } 342 | } 343 | 344 | updateChecked(): void { 345 | if (this.itemElement) { 346 | if (this.item.checked) { 347 | addClass(this.itemElement, 'checked') 348 | this.itemElement.setAttribute('aria-checked', 'true') 349 | } else { 350 | removeClass(this.itemElement, 'checked') 351 | this.itemElement.setAttribute('aria-checked', 'false') 352 | } 353 | } 354 | } 355 | 356 | updateRadioGroup(): void { 357 | if (this.radioGroup === undefined) { 358 | this.radioGroup = this.getRadioGroup() 359 | } 360 | 361 | if (this.menuItems) { 362 | for (let i = this.radioGroup.start; i < this.radioGroup.end; i++) { 363 | const menuItem = this.menuItems[i] 364 | if (menuItem instanceof CETMenuItem && menuItem.item.type === 'radio') { 365 | // update item.checked for each radio button in group 366 | menuItem.item.checked = menuItem === this 367 | menuItem.updateIcon() 368 | // updateChecked() *all* radio buttons in group 369 | menuItem.updateChecked() 370 | // set the radioGroup property of all the other radio buttons since it was already calculated 371 | if (menuItem !== this) { 372 | menuItem.radioGroup = this.radioGroup 373 | } 374 | } 375 | } 376 | } 377 | } 378 | 379 | /** radioGroup index's starts with (previous separator +1 OR menuItems[0]) and ends with (next separator OR menuItems[length]) */ 380 | getRadioGroup(): { start: number, end: number } { 381 | let startIndex = 0 382 | let endIndex = this.menuItems ? this.menuItems.length : 0 383 | let found = false 384 | 385 | if (this.menuItems) { 386 | for (const index in this.menuItems) { 387 | const menuItem = this.menuItems[index] 388 | if (menuItem === this) { 389 | found = true 390 | } else if (menuItem instanceof CETMenuItem && menuItem.isSeparator()) { 391 | if (found) { 392 | endIndex = Number.parseInt(index) 393 | break 394 | } else { 395 | startIndex = Number.parseInt(index) + 1 396 | } 397 | } 398 | } 399 | } 400 | 401 | return { start: startIndex, end: endIndex } 402 | } 403 | 404 | get element() { 405 | return this._currentElement 406 | } 407 | 408 | get item(): MenuItem { 409 | return this._item 410 | } 411 | 412 | isEnabled(): boolean { 413 | return this.item.enabled 414 | } 415 | 416 | isSeparator(): boolean { 417 | return this.item.type === 'separator' 418 | } 419 | 420 | get mnemonic(): KeyCode | undefined { 421 | return this._mnemonic 422 | } 423 | 424 | dispose(): void { 425 | if (this.itemElement) { 426 | removeNode(this.itemElement) 427 | this.itemElement = undefined 428 | } 429 | 430 | super.dispose() 431 | } 432 | } -------------------------------------------------------------------------------- /src/menubar/menu/index.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) AlexTorresDev. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | import { Menu, MenuItem } from 'electron' 7 | import { $, EventHelper, EventLike, EventType, addDisposableListener, append, hasClass, isAncestor, removeNode } from 'base/common/dom' 8 | import { Disposable, dispose } from 'base/common/lifecycle' 9 | import { CETMenuItem, IMenuItem, IMenuStyle } from './item' 10 | import { KeyCode, KeyCodeUtils, KeyMod } from 'base/common/keyCodes' 11 | import { StandardKeyboardEvent } from 'base/browser/keyboardEvent' 12 | import { Color, RGBA } from 'base/common/color' 13 | import { Emitter, Event } from 'base/common/event' 14 | import { MenuBarOptions } from '../menubar-options' 15 | import { CETSeparator } from './separator' 16 | import { CETSubMenu, ISubMenuData } from './submenu' 17 | import { isLinux, isFreeBSD } from 'base/common/platform' 18 | import { IMenuIcons } from 'menubar' 19 | 20 | export enum Direction { 21 | Right, 22 | Left 23 | } 24 | 25 | export interface IMenuOptions { 26 | ariaLabel?: string 27 | enableMnemonics?: boolean 28 | } 29 | 30 | interface ActionTrigger { 31 | keys: KeyCode[] 32 | keyDown: boolean 33 | } 34 | 35 | export class CETMenu extends Disposable { 36 | private focusedItem?: number = undefined 37 | private items: IMenuItem[] = [] 38 | 39 | private mnemonics: Map> 40 | 41 | private triggerKeys: ActionTrigger = { 42 | keys: [KeyCode.Enter, KeyCode.Space], 43 | keyDown: true 44 | } 45 | 46 | parentData: ISubMenuData = { 47 | parent: this 48 | } 49 | 50 | private _onDidCancel = this._register(new Emitter()) 51 | 52 | constructor(private menuContainer: HTMLElement, private menuIcons: IMenuIcons, private parentOptions: MenuBarOptions, private currentOptions: IMenuOptions, private closeSubMenu = () => { }) { 53 | super() 54 | 55 | this.mnemonics = new Map>() 56 | 57 | this._register(addDisposableListener(this.menuContainer, EventType.KEY_DOWN, e => { 58 | const event = new StandardKeyboardEvent(e) 59 | let eventHandled = true 60 | 61 | if (event.equals(KeyCode.UpArrow)) { 62 | this.focusPrevious() 63 | } else if (event.equals(KeyCode.DownArrow)) { 64 | this.focusNext() 65 | } else if (event.equals(KeyCode.Escape)) { 66 | this.cancel() 67 | } else if (this.isTriggerKeyEvent(event)) { 68 | // Staying out of the else branch even if not triggered 69 | if (this.triggerKeys && this.triggerKeys.keyDown) { 70 | this.doTrigger(event) 71 | } 72 | } else { 73 | eventHandled = false 74 | } 75 | 76 | if (eventHandled) { 77 | event.preventDefault() 78 | event.stopPropagation() 79 | } 80 | })) 81 | 82 | this._register(addDisposableListener(this.menuContainer, EventType.KEY_UP, e => { 83 | const event = new StandardKeyboardEvent(e) 84 | 85 | // Run action on Enter/Space 86 | if (this.isTriggerKeyEvent(event)) { 87 | if (this.triggerKeys && !this.triggerKeys.keyDown) { 88 | this.doTrigger(event) 89 | } 90 | 91 | event.preventDefault() 92 | event.stopPropagation() 93 | // Recompute focused item 94 | } else if (event.equals(KeyCode.Tab) || event.equals(KeyMod.Shift | KeyCode.Tab)) { 95 | this.updateFocusedItem() 96 | } 97 | })) 98 | 99 | if (this.currentOptions.enableMnemonics) { 100 | this._register(addDisposableListener(this.menuContainer, EventType.KEY_DOWN, (e) => { 101 | const key = KeyCodeUtils.fromString(e.key) 102 | if (this.mnemonics.has(key)) { 103 | const items = this.mnemonics.get(key)! 104 | 105 | if (items.length === 1) { 106 | if (items[0] instanceof CETSubMenu) { 107 | this.focusItemByElement(items[0].element) 108 | } 109 | 110 | items[0].onClick(e) 111 | } 112 | 113 | if (items.length > 1) { 114 | const item = items.shift() 115 | if (item) { 116 | this.focusItemByElement(item.element) 117 | items.push(item) 118 | } 119 | 120 | this.mnemonics.set(key, items) 121 | } 122 | } 123 | })) 124 | } 125 | 126 | if (isLinux) { 127 | this._register(addDisposableListener(this.menuContainer, EventType.KEY_DOWN, e => { 128 | const event = new StandardKeyboardEvent(e) 129 | 130 | if (event.equals(KeyCode.Home) || event.equals(KeyCode.PageUp)) { 131 | this.focusedItem = this.items.length - 1 132 | this.focusNext() 133 | EventHelper.stop(e, true) 134 | } else if (event.equals(KeyCode.End) || event.equals(KeyCode.PageDown)) { 135 | this.focusedItem = 0 136 | this.focusPrevious() 137 | EventHelper.stop(e, true) 138 | } 139 | })) 140 | } 141 | 142 | if (isFreeBSD) { 143 | this._register(addDisposableListener(this.menuContainer, EventType.KEY_DOWN, e => { 144 | const event = new StandardKeyboardEvent(e) 145 | 146 | if (event.equals(KeyCode.Home) || event.equals(KeyCode.PageUp)) { 147 | this.focusedItem = this.items.length - 1 148 | this.focusNext() 149 | EventHelper.stop(e, true) 150 | } else if (event.equals(KeyCode.End) || event.equals(KeyCode.PageDown)) { 151 | this.focusedItem = 0 152 | this.focusPrevious() 153 | EventHelper.stop(e, true) 154 | } 155 | })) 156 | } 157 | 158 | this._register(addDisposableListener(this.menuContainer, EventType.MOUSE_OUT, e => { 159 | const relatedTarget = e.relatedTarget as HTMLElement 160 | if (!isAncestor(relatedTarget, this.menuContainer)) { 161 | this.focusedItem = undefined 162 | this.updateFocus() 163 | e.stopPropagation() 164 | } 165 | })) 166 | 167 | this._register(addDisposableListener(this.menuContainer, EventType.MOUSE_UP, e => { 168 | // Absorb clicks in menu dead space https://github.com/Microsoft/vscode/issues/63575 169 | EventHelper.stop(e, true) 170 | })) 171 | 172 | this._register(addDisposableListener(this.menuContainer, EventType.MOUSE_OVER, e => { 173 | let target = e.target as HTMLElement 174 | 175 | if (!target || !isAncestor(target, this.menuContainer) || target === this.menuContainer) { 176 | return 177 | } 178 | 179 | while (target.parentElement !== this.menuContainer && target.parentElement !== null) { 180 | target = target.parentElement 181 | } 182 | 183 | if (hasClass(target, 'cet-action-item')) { 184 | const lastFocusedItem = this.focusedItem 185 | this.setFocusedItem(target) 186 | 187 | if (lastFocusedItem !== this.focusedItem) { 188 | this.updateFocus() 189 | } 190 | } 191 | })) 192 | 193 | if (this.currentOptions.ariaLabel) { 194 | this.menuContainer.setAttribute('aria-label', this.currentOptions.ariaLabel) 195 | } 196 | } 197 | 198 | trigger(index: number): void { 199 | if (index <= this.items.length && index >= 0) { 200 | const item = this.items[index] 201 | 202 | if (item instanceof CETSubMenu) { 203 | this.focus(index) 204 | } 205 | } 206 | } 207 | 208 | createMenu(menuItems: MenuItem[] | undefined) { 209 | if (!menuItems) return 210 | 211 | menuItems.forEach((menuItem: MenuItem) => { 212 | if (!menuItem) return 213 | 214 | const itemElement = $('li.cet-action-item', { role: 'presentation' }) 215 | 216 | // Prevent native context menu on actions 217 | this._register(addDisposableListener(itemElement, EventType.CONTEXT_MENU, (e: EventLike) => { 218 | e.preventDefault() 219 | e.stopPropagation() 220 | })) 221 | 222 | let item: CETMenuItem 223 | 224 | if (menuItem.type === 'separator') { 225 | item = new CETSeparator(menuItem, this.menuIcons, this.parentOptions, this.currentOptions) 226 | } else if (menuItem.type === 'submenu' || menuItem.submenu) { 227 | const submenuItems = (menuItem.submenu as Menu).items 228 | item = new CETSubMenu(menuItem, this.menuIcons, submenuItems, this.parentData, this.parentOptions, this.currentOptions, this.closeSubMenu) 229 | 230 | if (this.currentOptions.enableMnemonics) { 231 | const mnemonic = item.mnemonic 232 | 233 | if (mnemonic && item.isEnabled()) { 234 | let actionItems: CETMenuItem[] = [] 235 | if (this.mnemonics.has(mnemonic)) { 236 | actionItems = this.mnemonics.get(mnemonic)! 237 | } 238 | 239 | actionItems.push(item) 240 | 241 | this.mnemonics.set(mnemonic, actionItems) 242 | } 243 | } 244 | } else { 245 | item = new CETMenuItem(menuItem, this.menuIcons, this.parentOptions, this.currentOptions, this.items, this.closeSubMenu) 246 | 247 | if (this.currentOptions.enableMnemonics) { 248 | const mnemonic = item.mnemonic 249 | 250 | if (mnemonic && item.isEnabled()) { 251 | let actionItems: CETMenuItem[] = [] 252 | 253 | if (this.mnemonics.has(mnemonic)) { 254 | actionItems = this.mnemonics.get(mnemonic)! 255 | } 256 | 257 | actionItems.push(item) 258 | 259 | this.mnemonics.set(mnemonic, actionItems) 260 | } 261 | } 262 | } 263 | 264 | item.render(itemElement) 265 | this.items.push(item) 266 | append(this.menuContainer, itemElement) 267 | }) 268 | } 269 | 270 | private isTriggerKeyEvent(event: StandardKeyboardEvent): boolean { 271 | let ret = false 272 | if (this.triggerKeys) { 273 | this.triggerKeys.keys.forEach(keyCode => { 274 | ret = ret || event.equals(keyCode) 275 | }) 276 | } 277 | 278 | return ret 279 | } 280 | 281 | private updateFocusedItem(): void { 282 | for (let i = 0; i < this.menuContainer.children.length; i++) { 283 | const elem = this.menuContainer.children[i] 284 | if (isAncestor(document.activeElement, elem)) { 285 | this.focusedItem = i 286 | break 287 | } 288 | } 289 | } 290 | 291 | focus(index?: number): void 292 | focus(selectFirst?: boolean): void 293 | focus(arg?: any): void { 294 | let selectFirst: boolean = false 295 | let index: number | undefined 296 | 297 | if (arg === undefined) { 298 | selectFirst = true 299 | } else if (typeof arg === 'number') { 300 | index = arg 301 | } else if (typeof arg === 'boolean') { 302 | selectFirst = arg 303 | } 304 | 305 | if (selectFirst && typeof this.focusedItem === 'undefined') { 306 | // Focus the first enabled item 307 | this.focusedItem = this.items.length - 1 308 | this.focusNext() 309 | } else { 310 | if (index !== undefined) { 311 | this.focusedItem = index 312 | } 313 | 314 | this.updateFocus() 315 | } 316 | } 317 | 318 | private focusNext(): void { 319 | if (typeof this.focusedItem === 'undefined') { 320 | this.focusedItem = this.items.length - 1 321 | } 322 | 323 | const startIndex = this.focusedItem 324 | let item: IMenuItem 325 | 326 | do { 327 | this.focusedItem = (this.focusedItem + 1) % this.items.length 328 | item = this.items[this.focusedItem] 329 | } while ((this.focusedItem !== startIndex && !item.isEnabled()) || item.isSeparator()) 330 | 331 | if ((this.focusedItem === startIndex && !item.isEnabled()) || item.isSeparator()) { 332 | this.focusedItem = undefined 333 | } 334 | 335 | this.updateFocus() 336 | } 337 | 338 | private focusPrevious(): void { 339 | if (typeof this.focusedItem === 'undefined') { 340 | this.focusedItem = 0 341 | } 342 | 343 | const startIndex = this.focusedItem 344 | let item: IMenuItem 345 | 346 | do { 347 | this.focusedItem = this.focusedItem - 1 348 | 349 | if (this.focusedItem < 0) { 350 | this.focusedItem = this.items.length - 1 351 | } 352 | 353 | item = this.items[this.focusedItem] 354 | } while ((this.focusedItem !== startIndex && !item.isEnabled()) || item.isSeparator()) 355 | 356 | if ((this.focusedItem === startIndex && !item.isEnabled()) || item.isSeparator()) { 357 | this.focusedItem = undefined 358 | } 359 | 360 | this.updateFocus() 361 | } 362 | 363 | private updateFocus() { 364 | if (typeof this.focusedItem === 'undefined') { 365 | this.menuContainer.focus() 366 | } 367 | 368 | for (let i = 0; i < this.items.length; i++) { 369 | const item = this.items[i] 370 | 371 | if (i === this.focusedItem) { 372 | if (item.isEnabled()) { 373 | item.focus() 374 | } else { 375 | this.menuContainer.focus() 376 | } 377 | } else { 378 | item.blur() 379 | } 380 | } 381 | } 382 | 383 | private doTrigger(event: StandardKeyboardEvent): void { 384 | if (typeof this.focusedItem === 'undefined') { 385 | return // nothing to focus 386 | } 387 | 388 | // trigger action 389 | const item = this.items[this.focusedItem] 390 | if (item instanceof CETMenuItem) { 391 | item.onClick(event) 392 | } 393 | } 394 | 395 | private cancel(): void { 396 | if (document.activeElement instanceof HTMLElement) { 397 | (document.activeElement).blur() // remove focus from focused action 398 | } 399 | 400 | this._onDidCancel.fire() 401 | } 402 | 403 | private focusItemByElement(element: HTMLElement | undefined) { 404 | const lastFocusedItem = this.focusedItem 405 | if (element) this.setFocusedItem(element) 406 | 407 | if (lastFocusedItem !== this.focusedItem) { 408 | this.updateFocus() 409 | } 410 | } 411 | 412 | private setFocusedItem(element: HTMLElement) { 413 | this.focusedItem = Array.prototype.findIndex.call(this.container.children, (elem) => elem === element) 414 | } 415 | 416 | applyStyle(style: IMenuStyle) { 417 | const container = this.menuContainer 418 | 419 | if (style?.backgroundColor) { 420 | let transparency = this.parentOptions?.menuTransparency! 421 | 422 | if (transparency < 0) transparency = 0 423 | if (transparency > 1) transparency = 1 424 | const rgba = style.backgroundColor?.rgba 425 | container.style.backgroundColor = `rgb(${rgba.r} ${rgba.g} ${rgba.b} / ${1 - transparency})` 426 | } 427 | 428 | if (this.items) { 429 | this.items.forEach(item => { 430 | if (item instanceof CETMenuItem || item instanceof CETSeparator) { 431 | item.updateStyle(style) 432 | } 433 | }) 434 | } 435 | } 436 | 437 | get container(): HTMLElement { 438 | return this.menuContainer 439 | } 440 | 441 | get onDidCancel(): Event { 442 | return this._onDidCancel.event 443 | } 444 | 445 | dispose() { 446 | dispose(this.items) 447 | this.items = [] 448 | 449 | removeNode(this.container) 450 | 451 | super.dispose() 452 | } 453 | } -------------------------------------------------------------------------------- /src/base/common/color.ts: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *-------------------------------------------------------------------------------------------- */ 5 | 6 | /* eslint-disable no-inner-declarations */ 7 | /* eslint-disable indent */ 8 | 9 | import { CharCode } from 'base/common/charCode' 10 | 11 | function roundFloat(number: number, decimalPoints: number): number { 12 | const decimal = Math.pow(10, decimalPoints) 13 | return Math.round(number * decimal) / decimal 14 | } 15 | 16 | export class RGBA { 17 | /** 18 | * Red: integer in [0-255] 19 | */ 20 | readonly r: number 21 | 22 | /** 23 | * Green: integer in [0-255] 24 | */ 25 | readonly g: number 26 | 27 | /** 28 | * Blue: integer in [0-255] 29 | */ 30 | readonly b: number 31 | 32 | /** 33 | * Alpha: float in [0-1] 34 | */ 35 | readonly a: number 36 | 37 | constructor(r: number, g: number, b: number, a = 1) { 38 | this.r = Math.min(255, Math.max(0, r)) | 0 39 | this.g = Math.min(255, Math.max(0, g)) | 0 40 | this.b = Math.min(255, Math.max(0, b)) | 0 41 | this.a = roundFloat(Math.max(Math.min(1, a), 0), 3) 42 | } 43 | 44 | static equals(a: RGBA, b: RGBA): boolean { 45 | return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a 46 | } 47 | } 48 | 49 | export class HSLA { 50 | /** 51 | * Hue: integer in [0, 360] 52 | */ 53 | readonly h: number 54 | 55 | /** 56 | * Saturation: float in [0, 1] 57 | */ 58 | readonly s: number 59 | 60 | /** 61 | * Luminosity: float in [0, 1] 62 | */ 63 | readonly l: number 64 | 65 | /** 66 | * Alpha: float in [0, 1] 67 | */ 68 | readonly a: number 69 | 70 | constructor(h: number, s: number, l: number, a: number) { 71 | this.h = Math.max(Math.min(360, h), 0) | 0 72 | this.s = roundFloat(Math.max(Math.min(1, s), 0), 3) 73 | this.l = roundFloat(Math.max(Math.min(1, l), 0), 3) 74 | this.a = roundFloat(Math.max(Math.min(1, a), 0), 3) 75 | } 76 | 77 | static equals(a: HSLA, b: HSLA): boolean { 78 | return a.h === b.h && a.s === b.s && a.l === b.l && a.a === b.a 79 | } 80 | 81 | /** 82 | * Converts an RGB color value to HSL. Conversion formula 83 | * adapted from http://en.wikipedia.org/wiki/HSL_color_space. 84 | * Assumes r, g, and b are contained in the set [0, 255] and 85 | * returns h in the set [0, 360], s, and l in the set [0, 1]. 86 | */ 87 | static fromRGBA(rgba: RGBA): HSLA { 88 | const r = rgba.r / 255 89 | const g = rgba.g / 255 90 | const b = rgba.b / 255 91 | const a = rgba.a 92 | 93 | const max = Math.max(r, g, b) 94 | const min = Math.min(r, g, b) 95 | let h = 0 96 | let s = 0 97 | const l = (min + max) / 2 98 | const chroma = max - min 99 | 100 | if (chroma > 0) { 101 | s = Math.min((l <= 0.5 ? chroma / (2 * l) : chroma / (2 - (2 * l))), 1) 102 | 103 | switch (max) { 104 | case r: h = (g - b) / chroma + (g < b ? 6 : 0); break 105 | case g: h = (b - r) / chroma + 2; break 106 | case b: h = (r - g) / chroma + 4; break 107 | } 108 | 109 | h *= 60 110 | h = Math.round(h) 111 | } 112 | return new HSLA(h, s, l, a) 113 | } 114 | 115 | private static _hue2rgb(p: number, q: number, t: number): number { 116 | if (t < 0) { 117 | t += 1 118 | } 119 | if (t > 1) { 120 | t -= 1 121 | } 122 | if (t < 1 / 6) { 123 | return p + (q - p) * 6 * t 124 | } 125 | if (t < 1 / 2) { 126 | return q 127 | } 128 | if (t < 2 / 3) { 129 | return p + (q - p) * (2 / 3 - t) * 6 130 | } 131 | return p 132 | } 133 | 134 | /** 135 | * Converts an HSL color value to RGB. Conversion formula 136 | * adapted from http://en.wikipedia.org/wiki/HSL_color_space. 137 | * Assumes h in the set [0, 360] s, and l are contained in the set [0, 1] and 138 | * returns r, g, and b in the set [0, 255]. 139 | */ 140 | static toRGBA(hsla: HSLA): RGBA { 141 | const h = hsla.h / 360 142 | const { s, l, a } = hsla 143 | let r: number, g: number, b: number 144 | 145 | if (s === 0) { 146 | r = g = b = l // achromatic 147 | } else { 148 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s 149 | const p = 2 * l - q 150 | r = HSLA._hue2rgb(p, q, h + 1 / 3) 151 | g = HSLA._hue2rgb(p, q, h) 152 | b = HSLA._hue2rgb(p, q, h - 1 / 3) 153 | } 154 | 155 | return new RGBA(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a) 156 | } 157 | } 158 | 159 | export class HSVA { 160 | /** 161 | * Hue: integer in [0, 360] 162 | */ 163 | readonly h: number 164 | 165 | /** 166 | * Saturation: float in [0, 1] 167 | */ 168 | readonly s: number 169 | 170 | /** 171 | * Value: float in [0, 1] 172 | */ 173 | readonly v: number 174 | 175 | /** 176 | * Alpha: float in [0, 1] 177 | */ 178 | readonly a: number 179 | 180 | constructor(h: number, s: number, v: number, a: number) { 181 | this.h = Math.max(Math.min(360, h), 0) | 0 182 | this.s = roundFloat(Math.max(Math.min(1, s), 0), 3) 183 | this.v = roundFloat(Math.max(Math.min(1, v), 0), 3) 184 | this.a = roundFloat(Math.max(Math.min(1, a), 0), 3) 185 | } 186 | 187 | static equals(a: HSVA, b: HSVA): boolean { 188 | return a.h === b.h && a.s === b.s && a.v === b.v && a.a === b.a 189 | } 190 | 191 | // from http://www.rapidtables.com/convert/color/rgb-to-hsv.htm 192 | static fromRGBA(rgba: RGBA): HSVA { 193 | const r = rgba.r / 255 194 | const g = rgba.g / 255 195 | const b = rgba.b / 255 196 | const cmax = Math.max(r, g, b) 197 | const cmin = Math.min(r, g, b) 198 | const delta = cmax - cmin 199 | const s = cmax === 0 ? 0 : (delta / cmax) 200 | let m: number 201 | 202 | if (delta === 0) { 203 | m = 0 204 | } else if (cmax === r) { 205 | m = ((((g - b) / delta) % 6) + 6) % 6 206 | } else if (cmax === g) { 207 | m = ((b - r) / delta) + 2 208 | } else { 209 | m = ((r - g) / delta) + 4 210 | } 211 | 212 | return new HSVA(Math.round(m * 60), s, cmax, rgba.a) 213 | } 214 | 215 | // from http://www.rapidtables.com/convert/color/hsv-to-rgb.htm 216 | static toRGBA(hsva: HSVA): RGBA { 217 | const { h, s, v, a } = hsva 218 | const c = v * s 219 | const x = c * (1 - Math.abs((h / 60) % 2 - 1)) 220 | const m = v - c 221 | let [r, g, b] = [0, 0, 0] 222 | 223 | if (h < 60) { 224 | r = c 225 | g = x 226 | } else if (h < 120) { 227 | r = x 228 | g = c 229 | } else if (h < 180) { 230 | g = c 231 | b = x 232 | } else if (h < 240) { 233 | g = x 234 | b = c 235 | } else if (h < 300) { 236 | r = x 237 | b = c 238 | } else if (h < 360) { 239 | r = c 240 | b = x 241 | } 242 | 243 | r = Math.round((r + m) * 255) 244 | g = Math.round((g + m) * 255) 245 | b = Math.round((b + m) * 255) 246 | 247 | return new RGBA(r, g, b, a) 248 | } 249 | } 250 | 251 | export class Color { 252 | static fromHex(hex: string): Color { 253 | return Color.Format.CSS.parseHex(hex) || Color.RED 254 | } 255 | 256 | readonly rgba: RGBA 257 | private _hsla?: HSLA 258 | get hsla(): HSLA { 259 | if (this._hsla) { 260 | return this._hsla 261 | } else { 262 | return HSLA.fromRGBA(this.rgba) 263 | } 264 | } 265 | 266 | private _hsva?: HSVA 267 | get hsva(): HSVA { 268 | if (this._hsva) { 269 | return this._hsva 270 | } 271 | return HSVA.fromRGBA(this.rgba) 272 | } 273 | 274 | constructor(arg: RGBA | HSLA | HSVA) { 275 | if (!arg) { 276 | throw new Error('Color needs a value') 277 | } else if (arg instanceof RGBA) { 278 | this.rgba = arg 279 | } else if (arg instanceof HSLA) { 280 | this._hsla = arg 281 | this.rgba = HSLA.toRGBA(arg) 282 | } else if (arg instanceof HSVA) { 283 | this._hsva = arg 284 | this.rgba = HSVA.toRGBA(arg) 285 | } else { 286 | throw new Error('Invalid color ctor argument') 287 | } 288 | } 289 | 290 | equals(other: Color): boolean { 291 | return !!other && RGBA.equals(this.rgba, other.rgba) && HSLA.equals(this.hsla, other.hsla) && HSVA.equals(this.hsva, other.hsva) 292 | } 293 | 294 | /** 295 | * http://www.w3.org/TR/WCAG20/#relativeluminancedef 296 | * Returns the number in the set [0, 1]. O => Darkest Black. 1 => Lightest white. 297 | */ 298 | getRelativeLuminance(): number { 299 | const R = Color._relativeLuminanceForComponent(this.rgba.r) 300 | const G = Color._relativeLuminanceForComponent(this.rgba.g) 301 | const B = Color._relativeLuminanceForComponent(this.rgba.b) 302 | const luminance = 0.2126 * R + 0.7152 * G + 0.0722 * B 303 | 304 | return roundFloat(luminance, 4) 305 | } 306 | 307 | private static _relativeLuminanceForComponent(color: number): number { 308 | const c = color / 255 309 | return (c <= 0.03928) ? c / 12.92 : Math.pow(((c + 0.055) / 1.055), 2.4) 310 | } 311 | 312 | /** 313 | * http://www.w3.org/TR/WCAG20/#contrast-ratiodef 314 | * Returns the contrast ration number in the set [1, 21]. 315 | */ 316 | getContrastRatio(another: Color): number { 317 | const lum1 = this.getRelativeLuminance() 318 | const lum2 = another.getRelativeLuminance() 319 | return lum1 > lum2 ? (lum1 + 0.05) / (lum2 + 0.05) : (lum2 + 0.05) / (lum1 + 0.05) 320 | } 321 | 322 | /** 323 | * http://24ways.org/2010/calculating-color-contrast 324 | * Return 'true' if darker color otherwise 'false' 325 | */ 326 | isDarker(): boolean { 327 | const yiq = (this.rgba.r * 299 + this.rgba.g * 587 + this.rgba.b * 114) / 1000 328 | return yiq < 128 329 | } 330 | 331 | /** 332 | * http://24ways.org/2010/calculating-color-contrast 333 | * Return 'true' if lighter color otherwise 'false' 334 | */ 335 | isLighter(): boolean { 336 | const yiq = (this.rgba.r * 299 + this.rgba.g * 587 + this.rgba.b * 114) / 1000 337 | return yiq >= 128 338 | } 339 | 340 | isLighterThan(another: Color): boolean { 341 | const lum1 = this.getRelativeLuminance() 342 | const lum2 = another.getRelativeLuminance() 343 | return lum1 > lum2 344 | } 345 | 346 | isDarkerThan(another: Color): boolean { 347 | const lum1 = this.getRelativeLuminance() 348 | const lum2 = another.getRelativeLuminance() 349 | return lum1 < lum2 350 | } 351 | 352 | lighten(factor: number): Color { 353 | return new Color(new HSLA(this.hsla.h, this.hsla.s, this.hsla.l + this.hsla.l * factor, this.hsla.a)) 354 | } 355 | 356 | darken(factor: number): Color { 357 | return new Color(new HSLA(this.hsla.h, this.hsla.s, this.hsla.l - this.hsla.l * factor, this.hsla.a)) 358 | } 359 | 360 | transparent(factor: number): Color { 361 | const { r, g, b, a } = this.rgba 362 | return new Color(new RGBA(r, g, b, a * factor)) 363 | } 364 | 365 | isTransparent(): boolean { 366 | return this.rgba.a === 0 367 | } 368 | 369 | isOpaque(): boolean { 370 | return this.rgba.a === 1 371 | } 372 | 373 | opposite(): Color { 374 | return new Color(new RGBA(255 - this.rgba.r, 255 - this.rgba.g, 255 - this.rgba.b, this.rgba.a)) 375 | } 376 | 377 | blend(c: Color): Color { 378 | const rgba = c.rgba 379 | 380 | // Convert to 0..1 opacity 381 | const thisA = this.rgba.a 382 | const colorA = rgba.a 383 | 384 | const a = thisA + colorA * (1 - thisA) 385 | if (a < 1e-6) { 386 | return Color.TRANSPARENT 387 | } 388 | 389 | const r = this.rgba.r * thisA / a + rgba.r * colorA * (1 - thisA) / a 390 | const g = this.rgba.g * thisA / a + rgba.g * colorA * (1 - thisA) / a 391 | const b = this.rgba.b * thisA / a + rgba.b * colorA * (1 - thisA) / a 392 | 393 | return new Color(new RGBA(r, g, b, a)) 394 | } 395 | 396 | flatten(...backgrounds: Color[]): Color { 397 | const background = backgrounds.reduceRight((accumulator, color) => { 398 | return Color._flatten(color, accumulator) 399 | }) 400 | return Color._flatten(this, background) 401 | } 402 | 403 | private static _flatten(foreground: Color, background: Color) { 404 | const backgroundAlpha = 1 - foreground.rgba.a 405 | return new Color(new RGBA( 406 | backgroundAlpha * background.rgba.r + foreground.rgba.a * foreground.rgba.r, 407 | backgroundAlpha * background.rgba.g + foreground.rgba.a * foreground.rgba.g, 408 | backgroundAlpha * background.rgba.b + foreground.rgba.a * foreground.rgba.b 409 | )) 410 | } 411 | 412 | toString(): string { 413 | return '' + Color.Format.CSS.format(this) 414 | } 415 | 416 | static getLighterColor(of: Color, relative: Color, factor?: number): Color { 417 | if (of.isLighterThan(relative)) { 418 | return of 419 | } 420 | factor = factor || 0.5 421 | const lum1 = of.getRelativeLuminance() 422 | const lum2 = relative.getRelativeLuminance() 423 | factor = factor * (lum2 - lum1) / lum2 424 | return of.lighten(factor) 425 | } 426 | 427 | static getDarkerColor(of: Color, relative: Color, factor?: number): Color { 428 | if (of.isDarkerThan(relative)) { 429 | return of 430 | } 431 | factor = factor || 0.5 432 | const lum1 = of.getRelativeLuminance() 433 | const lum2 = relative.getRelativeLuminance() 434 | factor = factor * (lum1 - lum2) / lum1 435 | return of.darken(factor) 436 | } 437 | 438 | static readonly WHITE = new Color(new RGBA(255, 255, 255, 1)) 439 | static readonly BLACK = new Color(new RGBA(0, 0, 0, 1)) 440 | static readonly RED = new Color(new RGBA(255, 0, 0, 1)) 441 | static readonly BLUE = new Color(new RGBA(0, 0, 255, 1)) 442 | static readonly GREEN = new Color(new RGBA(0, 255, 0, 1)) 443 | static readonly CYAN = new Color(new RGBA(0, 255, 255, 1)) 444 | static readonly LIGHTGREY = new Color(new RGBA(211, 211, 211, 1)) 445 | static readonly TRANSPARENT = new Color(new RGBA(0, 0, 0, 0)) 446 | } 447 | 448 | export namespace Color { 449 | export namespace Format { 450 | export namespace CSS { 451 | 452 | export function formatRGB(color: Color): string { 453 | if (color.rgba.a === 1) { 454 | return `rgb(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b})` 455 | } 456 | 457 | return Color.Format.CSS.formatRGBA(color) 458 | } 459 | 460 | export function formatRGBA(color: Color): string { 461 | return `rgba(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b}, ${+(color.rgba.a).toFixed(2)})` 462 | } 463 | 464 | export function formatHSL(color: Color): string { 465 | if (color.hsla.a === 1) { 466 | return `hsl(${color.hsla.h}, ${(color.hsla.s * 100).toFixed(2)}%, ${(color.hsla.l * 100).toFixed(2)}%)` 467 | } 468 | 469 | return Color.Format.CSS.formatHSLA(color) 470 | } 471 | 472 | export function formatHSLA(color: Color): string { 473 | return `hsla(${color.hsla.h}, ${(color.hsla.s * 100).toFixed(2)}%, ${(color.hsla.l * 100).toFixed(2)}%, ${color.hsla.a.toFixed(2)})` 474 | } 475 | 476 | function _toTwoDigitHex(n: number): string { 477 | const r = n.toString(16) 478 | return r.length !== 2 ? '0' + r : r 479 | } 480 | 481 | /** 482 | * Formats the color as #RRGGBB 483 | */ 484 | export function formatHex(color: Color): string { 485 | return `#${_toTwoDigitHex(color.rgba.r)}${_toTwoDigitHex(color.rgba.g)}${_toTwoDigitHex(color.rgba.b)}` 486 | } 487 | 488 | /** 489 | * Formats the color as #RRGGBBAA 490 | * If 'compact' is set, colors without transparancy will be printed as #RRGGBB 491 | */ 492 | export function formatHexA(color: Color, compact = false): string { 493 | if (compact && color.rgba.a === 1) { 494 | return Color.Format.CSS.formatHex(color) 495 | } 496 | 497 | return `#${_toTwoDigitHex(color.rgba.r)}${_toTwoDigitHex(color.rgba.g)}${_toTwoDigitHex(color.rgba.b)}${_toTwoDigitHex(Math.round(color.rgba.a * 255))}` 498 | } 499 | 500 | /** 501 | * The default format will use HEX if opaque and RGBA otherwise. 502 | */ 503 | export function format(color: Color): string | null { 504 | if (!color) { 505 | return null 506 | } 507 | 508 | if (color.isOpaque()) { 509 | return Color.Format.CSS.formatHex(color) 510 | } 511 | 512 | return Color.Format.CSS.formatRGBA(color) 513 | } 514 | 515 | /** 516 | * Converts an Hex color value to a Color. 517 | * returns r, g, and b are contained in the set [0, 255] 518 | * @param hex string (#RGB, #RGBA, #RRGGBB or #RRGGBBAA). 519 | */ 520 | export function parseHex(hex: string): Color | null { 521 | if (!hex) { 522 | // Invalid color 523 | return null 524 | } 525 | 526 | const length = hex.length 527 | 528 | if (length === 0) { 529 | // Invalid color 530 | return null 531 | } 532 | 533 | if (hex.charCodeAt(0) !== CharCode.Hash) { 534 | // Does not begin with a # 535 | return null 536 | } 537 | 538 | if (length === 7) { 539 | // #RRGGBB format 540 | const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)) 541 | const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)) 542 | const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)) 543 | return new Color(new RGBA(r, g, b, 1)) 544 | } 545 | 546 | if (length === 9) { 547 | // #RRGGBBAA format 548 | const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)) 549 | const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)) 550 | const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)) 551 | const a = 16 * _parseHexDigit(hex.charCodeAt(7)) + _parseHexDigit(hex.charCodeAt(8)) 552 | return new Color(new RGBA(r, g, b, a / 255)) 553 | } 554 | 555 | if (length === 4) { 556 | // #RGB format 557 | const r = _parseHexDigit(hex.charCodeAt(1)) 558 | const g = _parseHexDigit(hex.charCodeAt(2)) 559 | const b = _parseHexDigit(hex.charCodeAt(3)) 560 | return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b)) 561 | } 562 | 563 | if (length === 5) { 564 | // #RGBA format 565 | const r = _parseHexDigit(hex.charCodeAt(1)) 566 | const g = _parseHexDigit(hex.charCodeAt(2)) 567 | const b = _parseHexDigit(hex.charCodeAt(3)) 568 | const a = _parseHexDigit(hex.charCodeAt(4)) 569 | return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b, (16 * a + a) / 255)) 570 | } 571 | 572 | // Invalid color 573 | return null 574 | } 575 | 576 | function _parseHexDigit(charCode: CharCode): number { 577 | switch (charCode) { 578 | case CharCode.Digit0: return 0 579 | case CharCode.Digit1: return 1 580 | case CharCode.Digit2: return 2 581 | case CharCode.Digit3: return 3 582 | case CharCode.Digit4: return 4 583 | case CharCode.Digit5: return 5 584 | case CharCode.Digit6: return 6 585 | case CharCode.Digit7: return 7 586 | case CharCode.Digit8: return 8 587 | case CharCode.Digit9: return 9 588 | case CharCode.a: return 10 589 | case CharCode.A: return 10 590 | case CharCode.b: return 11 591 | case CharCode.B: return 11 592 | case CharCode.c: return 12 593 | case CharCode.C: return 12 594 | case CharCode.d: return 13 595 | case CharCode.D: return 13 596 | case CharCode.e: return 14 597 | case CharCode.E: return 14 598 | case CharCode.f: return 15 599 | case CharCode.F: return 15 600 | } 601 | return 0 602 | } 603 | } 604 | } 605 | } --------------------------------------------------------------------------------