├── .npmignore ├── index.d.ts ├── .gitignore ├── index.js ├── main.js ├── renderer.d.ts ├── src ├── main │ ├── index.ts │ └── setup.ts └── renderer │ ├── index.ts │ ├── utils.ts │ ├── titlebar.tsx │ ├── menu-list.tsx │ ├── window-controls.tsx │ ├── menu.tsx │ └── menu-list-item.tsx ├── main.d.ts ├── renderer.js ├── app ├── icon.png ├── screenshot.PNG ├── styles.css ├── main.html ├── index.js └── renderer.js ├── .prettierrc.js ├── .eslintrc.js ├── tsconfig.json ├── LICENSE.md ├── webpack.config.js ├── package.json ├── README.md └── assets └── style.css /.npmignore: -------------------------------------------------------------------------------- 1 | app 2 | node_modules 3 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './renderer' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./renderer') 2 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/main') 2 | -------------------------------------------------------------------------------- /renderer.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/renderer/index' 2 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | export { initialize } from './setup' 2 | -------------------------------------------------------------------------------- /main.d.ts: -------------------------------------------------------------------------------- 1 | export { initialize } from './dist/main/index'; 2 | -------------------------------------------------------------------------------- /renderer.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/renderer') 2 | -------------------------------------------------------------------------------- /app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KochiyaOcean/electron-react-titlebar/HEAD/app/icon.png -------------------------------------------------------------------------------- /app/screenshot.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KochiyaOcean/electron-react-titlebar/HEAD/app/screenshot.PNG -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "jsxSingleQuote": false, 7 | "endOfLine": "lf" 8 | } 9 | -------------------------------------------------------------------------------- /app/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | background: rgb(64,64,64); 3 | } 4 | 5 | body { 6 | margin: 0; 7 | padding: 0; 8 | position: absolute; 9 | left: 0; 10 | top: 0; 11 | right: 0; 12 | bottom: 0; 13 | } 14 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | export { TitleBar, TitleBarProps } from './titlebar' 2 | export { MenuItemT, MenuListItemProps } from './menu-list-item' 3 | export { MenuListProps } from './menu-list' 4 | export { MenuBarProps, MenuT } from './menu' 5 | export { WindowControlsProps } from './window-controls' 6 | -------------------------------------------------------------------------------- /app/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 6 | "prettier" 7 | ], 8 | "plugins": ["@typescript-eslint", "prettier"], 9 | "parser": "@typescript-eslint/parser", 10 | "parserOptions": { 11 | "sourceType": "module", 12 | "project": "./tsconfig.json" 13 | }, 14 | "env": { "browser": true, "node": true, "es6": true }, 15 | "rules": { 16 | "prettier/prettier": "error", 17 | }, 18 | "ignorePatterns": ["*.js", "*.d.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "target": "ES2018", 5 | "module": "CommonJS", 6 | "strict": true, 7 | "allowSyntheticDefaultImports": true, 8 | "moduleResolution": "Node", 9 | "lib": [ 10 | "DOM", 11 | "es2018", 12 | "esnext.AsyncIterable", 13 | "esnext.Array", 14 | "esnext.Intl", 15 | "esnext.Symbol" 16 | ], 17 | "declaration": true, 18 | "emitDeclarationOnly": true, 19 | "sourceMap": true, 20 | "outDir": "dist", 21 | "skipLibCheck": true, 22 | }, 23 | "include": ["./src/**/*"] 24 | } 25 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | const electron = require('electron') 2 | const { app, BrowserWindow } = electron 3 | 4 | let mainWindow 5 | 6 | const { initialize } = require('../dist/main') 7 | initialize() 8 | 9 | function createWindow() { 10 | mainWindow = new BrowserWindow({ 11 | width: 800, 12 | height: 480, 13 | frame: false, 14 | resizable: true, 15 | webPreferences: { 16 | nodeIntegration: true, 17 | contextIsolation: false, 18 | } 19 | }) 20 | 21 | mainWindow.loadURL('file://' + __dirname + '/main.html') 22 | 23 | if (process.env['DEBUG']) { 24 | mainWindow.webContents.openDevTools() 25 | } 26 | 27 | mainWindow.on('closed', function() { 28 | mainWindow = null 29 | }) 30 | } 31 | 32 | app.on('ready', createWindow) 33 | 34 | app.on('window-all-closed', function() { 35 | if (process.platform !== 'darwin') { 36 | app.quit() 37 | } 38 | }) 39 | 40 | app.on('activate', function() { 41 | if (mainWindow === null) { 42 | createWindow() 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /src/renderer/utils.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from 'lodash' 2 | 3 | type CommonTypes = number | boolean | string 4 | 5 | type Obj = T[] | { [key: string | number]: T } | T 6 | 7 | export function reduxSet(obj: Obj, path: (string | number)[], val: CommonTypes): Obj | CommonTypes { 8 | const [prop, ...restPath] = path 9 | if (typeof prop === 'undefined') { 10 | if (!isEqual(obj, val)) return val 11 | else return obj 12 | } 13 | let before 14 | if (prop in obj) { 15 | before = (obj as { [key: string | number]: T })[prop] 16 | } else { 17 | before = {} 18 | } 19 | const after = reduxSet(before, restPath, val) 20 | if (after !== before) { 21 | let result 22 | if (Array.isArray(obj)) { 23 | result = [...obj.slice(0, prop as number), after, ...obj.slice((prop as number) + 1, obj.length)] as T[] 24 | } else { 25 | result = { 26 | ...obj, 27 | [prop]: after, 28 | } as { [key: string | number]: T } 29 | } 30 | return result 31 | } 32 | return obj 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/titlebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { WindowControls } from './window-controls' 4 | import { MenuBar, MenuT } from './menu' 5 | 6 | export interface TitleBarProps { 7 | icon?: string 8 | menu?: MenuT[] 9 | disableMinimize?: boolean 10 | disableMaximize?: boolean 11 | className?: string 12 | browserWindowId?: number 13 | } 14 | 15 | export const TitleBar: React.FC = ({ 16 | children, 17 | icon, 18 | menu, 19 | disableMinimize, 20 | disableMaximize, 21 | className, 22 | browserWindowId, 23 | }) => ( 24 |
25 |
26 |
27 | {!!icon && } 28 | {!!menu && } 29 | {children} 30 | 35 |
36 | ) 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 kochiyaocean 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | 4 | module.exports = [{ 5 | entry: './src/renderer/index.ts', 6 | target: 'electron-renderer', 7 | output: { 8 | filename: 'renderer.js', 9 | path: resolve(__dirname, 'dist'), 10 | libraryTarget: 'commonjs2' 11 | }, 12 | resolve: { 13 | extensions: ['.ts', '.js', '.tsx', 'jsx'], 14 | }, 15 | devtool: 'source-map', 16 | externals: [nodeExternals()], 17 | externalsPresets: { 18 | electronRenderer: true 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.tsx?$/, 24 | use: { 25 | loader: 'babel-loader', 26 | options: { 27 | presets: [ 28 | [ 29 | '@babel/preset-env', 30 | { 31 | "targets": { 32 | "electron": 12 33 | } 34 | } 35 | ], 36 | '@babel/preset-typescript', 37 | '@babel/preset-react' 38 | ] 39 | } 40 | }, 41 | exclude: /node_modules/ 42 | } 43 | ] 44 | }, 45 | }, { 46 | entry: './src/main/index.ts', 47 | target: 'electron-main', 48 | output: { 49 | filename: 'main.js', 50 | path: resolve(__dirname, 'dist'), 51 | libraryTarget: 'commonjs2' 52 | }, 53 | resolve: { 54 | extensions: ['.ts', '.js'], 55 | }, 56 | devtool: 'source-map', 57 | externals: [nodeExternals()], 58 | externalsPresets: { 59 | electronMain: true 60 | }, 61 | module: { 62 | rules: [ 63 | { 64 | test: /\.ts$/, 65 | use: { 66 | loader: 'babel-loader', 67 | options: { 68 | presets: ['@babel/preset-env', '@babel/preset-typescript'] 69 | } 70 | }, 71 | exclude: /node_modules/ 72 | } 73 | ] 74 | }, 75 | }] 76 | -------------------------------------------------------------------------------- /src/main/setup.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain, BrowserWindow, WebContents } from 'electron' 2 | 3 | const setupEventListener = (browserWindow: BrowserWindow, sender: WebContents) => { 4 | browserWindow.addListener('maximize', () => { 5 | sender.send('electron-react-titlebar/maximunize/change', true, browserWindow.id) 6 | }) 7 | browserWindow.addListener('unmaximize', () => { 8 | sender.send('electron-react-titlebar/maximunize/change', false, browserWindow.id) 9 | }) 10 | } 11 | 12 | export const initialize = (): void => { 13 | ipcMain.handle('electron-react-titlebar/initialize', (event, browserWindowId: number): number | undefined => { 14 | const browserWindow = browserWindowId 15 | ? BrowserWindow.fromId(browserWindowId) 16 | : BrowserWindow.fromWebContents(event.sender) 17 | if (browserWindow) { 18 | setupEventListener(browserWindow, event.sender) 19 | return browserWindow.id 20 | } 21 | return undefined 22 | }) 23 | 24 | ipcMain.on('electron-react-titlebar/maximumize/set', (event, browserWindowId: number) => { 25 | const browserWindow = browserWindowId 26 | ? BrowserWindow.fromId(browserWindowId) 27 | : BrowserWindow.fromWebContents(event.sender) 28 | if (browserWindow?.isMaximizable()) { 29 | if (browserWindow.isMaximized()) { 30 | browserWindow.unmaximize() 31 | } else { 32 | browserWindow.maximize() 33 | } 34 | } 35 | }) 36 | 37 | ipcMain.on('electron-react-titlebar/minimumize/set', (event, browserWindowId: number) => { 38 | const browserWindow = browserWindowId 39 | ? BrowserWindow.fromId(browserWindowId) 40 | : BrowserWindow.fromWebContents(event.sender) 41 | browserWindow?.minimize() 42 | }) 43 | 44 | ipcMain.on('electron-react-titlebar/close', (event, browserWindowId: number) => { 45 | const browserWindow = browserWindowId 46 | ? BrowserWindow.fromId(browserWindowId) 47 | : BrowserWindow.fromWebContents(event.sender) 48 | browserWindow?.close() 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /app/renderer.js: -------------------------------------------------------------------------------- 1 | const React = require('react') 2 | const ReactDOM = require('react-dom') 3 | const { shell } = require('electron') 4 | const { openExternal } = shell 5 | 6 | const { TitleBar } = require('../dist/renderer') 7 | 8 | const template = [ 9 | { 10 | label: 'App', 11 | submenu: [ 12 | { 13 | label: 'Disabled', 14 | enabled: false, 15 | }, 16 | { 17 | label: 'Not Visiable', 18 | visiable: false, 19 | }, 20 | { 21 | label: 'Arguments', 22 | click: (item, e) => console.log(item, e), 23 | }, 24 | { type: 'separator' }, 25 | { 26 | label: 'Checkbox', 27 | type: 'checkbox', 28 | checked: true, 29 | click: (item, e) => console.log(item), 30 | }, 31 | { 32 | label: 'Quit', 33 | click: () => { 34 | window.close() 35 | }, 36 | }, 37 | ], 38 | }, 39 | { 40 | label: 'Color', 41 | submenu: [ 42 | { 43 | label: 'Light', 44 | type: 'radio', 45 | checked: false, 46 | click: (item, e) => document.querySelector('html').style.background = 'rgb(240,240,240)', 47 | }, 48 | { 49 | label: 'Dark', 50 | type: 'radio', 51 | checked: true, 52 | click: (item, e) => document.querySelector('html').style.background = 'rgb(64,64,64)', 53 | }, 54 | { 55 | label: 'Black', 56 | type: 'radio', 57 | checked: false, 58 | click: (item, e) => document.querySelector('html').style.background = 'rgb(0,0,0)', 59 | }, 60 | ], 61 | }, 62 | { 63 | label: 'Help', 64 | submenu: [ 65 | { 66 | label: 'Homepage', 67 | click: () => { 68 | openExternal('https://github.com/KochiyaOcean/electron-react-titlebar') 69 | }, 70 | }, 71 | ], 72 | }, 73 | ] 74 | 75 | ReactDOM.render(React.createElement(TitleBar, {icon: __dirname + '/icon.png', menu: template}), document.querySelector('.title')) 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-react-titlebar", 3 | "version": "1.2.1", 4 | "description": "A github desktop style title bar component for electron.", 5 | "main": "index.js", 6 | "exports": { 7 | ".": "./index.js", 8 | "./main": "./dist/main.js", 9 | "./renderer": "./dist/renderer.js", 10 | "./assets/style.css": "./assets/style.css", 11 | "./style": "./assets/style.css" 12 | }, 13 | "scripts": { 14 | "build": "tsc && webpack --mode=production", 15 | "demo": "electron app", 16 | "prepack": "npm run build", 17 | "test": "echo \"Error: no test specified\" && exit 1", 18 | "check": "eslint . --quiet --fix" 19 | }, 20 | "author": "KochiyaOcean", 21 | "license": "MIT", 22 | "keywords": [ 23 | "electron", 24 | "react", 25 | "titlebar", 26 | "title" 27 | ], 28 | "homepage": "https://github.com/KochiyaOcean/electron-react-titlebar", 29 | "repository": { 30 | "url": "https://github.com/KochiyaOcean/electron-react-titlebar" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/KochiyaOcean/electron-react-titlebar/issues", 34 | "email": "root@kochiyaocean.org" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.18.2", 38 | "@babel/eslint-parser": "^7.18.2", 39 | "@babel/preset-env": "^7.18.2", 40 | "@babel/preset-react": "^7.17.12", 41 | "@babel/preset-typescript": "^7.17.12", 42 | "@types/lodash": "^4.14.182", 43 | "@types/react": "^17.*", 44 | "@types/react-virtualized": "^9.21.21", 45 | "@types/react-window": "^1.8.5", 46 | "@typescript-eslint/eslint-plugin": "^5.27.1", 47 | "@typescript-eslint/parser": "^5.27.1", 48 | "babel-loader": "^8.2.5", 49 | "electron": "^19.0.4", 50 | "eslint": "^8.17.0", 51 | "eslint-config-prettier": "^8.5.0", 52 | "eslint-plugin-import": "^2.26.0", 53 | "eslint-plugin-prettier": "^4.0.0", 54 | "eslint-plugin-react": "^7.30.0", 55 | "prettier": "^2.6.2", 56 | "react": "^17.*", 57 | "react-dom": "^17.*", 58 | "typescript": "^4.7.3", 59 | "webpack": "^5.73.0", 60 | "webpack-cli": "^4.9.2", 61 | "webpack-node-externals": "^3.0.0" 62 | }, 63 | "dependencies": { 64 | "classnames": "^2.3.1", 65 | "lodash": "^4.17.21", 66 | "react-window": "^1.8.7" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/renderer/menu-list.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, useCallback } from 'react' 2 | import { VariableSizeGrid } from 'react-window' 3 | import { MenuItemT, MenuListItem } from './menu-list-item' 4 | 5 | export interface MenuListProps { 6 | rect: { 7 | left: number 8 | right: number 9 | top: number 10 | bottom: number 11 | } 12 | mainIndex: number 13 | menulist: MenuItemT[] 14 | changeCheckState: (mainIndex: number, subIndex: number, check: boolean, isRadio?: boolean) => void 15 | } 16 | 17 | type RendererProps = { 18 | columnIndex: number 19 | rowIndex: number 20 | style: CSSProperties 21 | } 22 | 23 | export const MenuList: React.FC = ({ rect, mainIndex, menulist, changeCheckState }) => { 24 | const getRowHeight = useCallback( 25 | (index: number): number => { 26 | const menuListItem = menulist[index] 27 | if (menuListItem.visiable === false) { 28 | return 0 29 | } else if (menuListItem.type === 'separator') { 30 | return 10 31 | } else { 32 | return 30 33 | } 34 | }, 35 | [menulist] 36 | ) 37 | 38 | const renderMenuList = useCallback( 39 | ({ rowIndex, style }: RendererProps) => ( 40 | 47 | ), 48 | [menulist, changeCheckState, mainIndex] 49 | ) 50 | 51 | const menuListHeight = menulist.map((l, index) => getRowHeight(index)).reduce((a, b) => a + b, 0) 52 | return ( 53 |
63 |
64 |
75 |
76 |
83 |
84 | 240} 88 | height={menuListHeight} 89 | rowHeight={getRowHeight} 90 | width={240} 91 | > 92 | {renderMenuList} 93 | 94 |
95 |
96 |
97 |
98 |
99 |
100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # electron-react-titlebar 2 | A github desktop style title bar component for electron. 3 | 4 | ![screenshot](https://github.com/KochiyaOcean/electron-react-titlebar/raw/master/app/screenshot.PNG) 5 | 6 | ## Installation 7 | 8 | ``` 9 | npm i --save electron-react-titlebar 10 | ``` 11 | 12 | ## Example 13 | 14 | ``` 15 | npm run build 16 | npm run demo 17 | ``` 18 | 19 | ## Usage 20 | 21 | ### Main process 22 | 23 | ```javascript 24 | app.on('ready', () => { 25 | require('electron-react-titlebar/main').initialize() 26 | }) 27 | ``` 28 | 29 | ### Renderer process 30 | 31 | #### If you are using webpack 32 | 33 | ```javascript 34 | import { TitleBar } from 'electron-react-titlebar/renderer' 35 | import 'electron-react-titlebar/assets/style.css' 36 | 37 | ReactDOM.render( 38 | , 39 | document.querySelector('title-bar') 40 | ) 41 | ``` 42 | 43 | #### If you're not a webpack user and wants to load css directly 44 | 45 | ```js 46 | ReactDOM.render( 47 | 48 | 49 | , 50 | document.body 51 | ) 52 | ``` 53 | 54 | ## Options 55 | 56 | ### children?: node 57 | 58 | Elements to be rendered in between the menu and the window controls (optional). 59 | 60 | ### disableMinimize?: boolean 61 | 62 | Disable minimize button (optional). 63 | 64 | ### disableMaximize?: boolean 65 | 66 | Disable maximize button (optional). 67 | 68 | ### icon?: string 69 | 70 | Path to icon file (optional). 71 | 72 | ### browserWindowId?: number 73 | 74 | The browserWindow's id that window controls affect to. Default value is the browserWindow that renders the component (optional). 75 | 76 | ### menu?: \ 77 | 78 | Menu template of [Electron's Menu](https://github.com/electron/electron/blob/master/docs/api/menu.md#main-process) (optional). 79 | 80 | Note: electron-react-titlebar is supporting a subset of [Electron's MenuItem](https://github.com/electron/electron/blob/master/docs/api/menu-item.md). 81 | 82 | Supported options: 83 | 84 | * `click` - supported, but the callback only have `item` and `event` parameter, and the `browserWindow` parameter is removed due to restriction since Electron@14 85 | * `type` - `submenu` is not supported. 86 | * `label` - supported 87 | * `enabled` - supported 88 | * `visible` - supported 89 | * `checked` - supported 90 | 91 | 92 | ## Breaking changes since v1.0.0 93 | 94 | The v1.0.0 version contains following breaking changes: 95 | 96 | - Requires `React@16.8` or newer version 97 | - Due to the Electron's removal of remote module since version 14, electron-react-titlebar now: 98 | - Needs to initialize in main process (See example above) 99 | - The require path of component has changed to `electron-react-titlebar/renderer` 100 | - The `currentWindow` prop has been removed, instead you can control your browserWindow by `browserWindowId` prop 101 | - The second parameter of callback of menuItem's click handler (the browserWindow) has been removed 102 | 103 | If you're still using Electron below 14 and don't want to take breaking changes, you can still use 0.x version of electron-react-titlebar. 104 | -------------------------------------------------------------------------------- /src/renderer/window-controls.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback, useRef } from 'react' 2 | import { ipcRenderer, IpcRendererEvent } from 'electron' 3 | 4 | export interface WindowControlsProps { 5 | disableMinimize?: boolean 6 | disableMaximize?: boolean 7 | browserWindowId?: number 8 | } 9 | 10 | export const WindowControls: React.FC = ({ 11 | disableMaximize, 12 | disableMinimize, 13 | browserWindowId, 14 | }) => { 15 | const [isMaximized, setIsMaximized] = useState(false) 16 | const remoteBrowserWindowId = useRef(browserWindowId) 17 | 18 | useEffect(() => { 19 | const onMaximimizeStateChange = ( 20 | event: IpcRendererEvent, 21 | isWindowMaximumized: boolean, 22 | targetBrowserWindowId: number 23 | ) => { 24 | if (targetBrowserWindowId === remoteBrowserWindowId.current) { 25 | setIsMaximized(isWindowMaximumized) 26 | } 27 | } 28 | 29 | ipcRenderer.on('electron-react-titlebar/maximunize/change', onMaximimizeStateChange) 30 | 31 | const updateRemoteBrowserWindowId = async () => { 32 | remoteBrowserWindowId.current = (await ipcRenderer.invoke( 33 | 'electron-react-titlebar/initialize', 34 | browserWindowId 35 | )) as number 36 | } 37 | updateRemoteBrowserWindowId().finally(() => null) 38 | 39 | return () => { 40 | ipcRenderer.removeListener('electron-react-titlebar/maximunize/change', onMaximimizeStateChange) 41 | } 42 | }, [browserWindowId]) 43 | 44 | const setMaximumize = useCallback(() => { 45 | ipcRenderer.send('electron-react-titlebar/maximumize/set', browserWindowId) 46 | }, [browserWindowId]) 47 | 48 | const setMinimumize = useCallback(() => { 49 | ipcRenderer.send('electron-react-titlebar/minimumize/set', browserWindowId) 50 | }, [browserWindowId]) 51 | 52 | const setClose = useCallback(() => { 53 | ipcRenderer.send('electron-react-titlebar/close', browserWindowId) 54 | }, [browserWindowId]) 55 | 56 | return ( 57 |
58 | 69 | 86 | 91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /src/renderer/menu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from 'react' 2 | import classnames from 'classnames' 3 | import { reduxSet } from './utils' 4 | import { MenuList } from './menu-list' 5 | import { MenuItemT } from './menu-list-item' 6 | 7 | export interface MenuT { 8 | label: string 9 | submenu: MenuItemT[] 10 | } 11 | 12 | export interface MenuBarProps { 13 | menu: MenuT[] 14 | } 15 | 16 | export const MenuBar: React.FC = ({ menu: propMenu }) => { 17 | const [clicked, setClicked] = useState(false) 18 | const [focusing, setFocusing] = useState(0) 19 | const [menu, setMenu] = useState(propMenu) 20 | const lock = useRef(false) 21 | const menuItems = useRef<{ [i: number]: HTMLDivElement }>({}) 22 | 23 | const onButtonMouseOver = useCallback( 24 | (i: number) => { 25 | if (clicked) { 26 | setFocusing(i) 27 | } 28 | }, 29 | [clicked] 30 | ) 31 | 32 | const onButtonClick = useCallback( 33 | (i) => { 34 | if (lock.current) { 35 | lock.current = false 36 | return 37 | } 38 | setClicked(!(focusing === i && clicked)) 39 | }, 40 | [clicked, focusing] 41 | ) 42 | 43 | const onTouchStart = useCallback( 44 | (i) => { 45 | if (i !== focusing && clicked) { 46 | lock.current = true 47 | } 48 | }, 49 | [clicked, focusing] 50 | ) 51 | 52 | const onMouseMove = (i: number) => { 53 | setFocusing(i) 54 | } 55 | 56 | const setRefs = (ref: HTMLDivElement, i: number) => { 57 | menuItems.current[i] = ref 58 | } 59 | 60 | useEffect(() => { 61 | setMenu(propMenu) 62 | }, [propMenu]) 63 | 64 | const changeCheckState = (mainIndex: number, subIndex: number, checked: boolean, isRadio = false) => { 65 | if (!isRadio) { 66 | setMenu(reduxSet(menu, [mainIndex, 'submenu', subIndex, 'checked'], checked) as MenuT[]) 67 | } else { 68 | let newMenu = [...menu] 69 | const menuLength = menu[mainIndex].submenu.length 70 | for (let i = 0; i < menuLength; i++) { 71 | if (menu[mainIndex].submenu[i].type === 'radio') { 72 | newMenu = reduxSet(newMenu, [mainIndex, 'submenu', i, 'checked'], i === subIndex) as MenuT[] 73 | } 74 | } 75 | setMenu(newMenu) 76 | } 77 | } 78 | 79 | return ( 80 | 118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /src/renderer/menu-list-item.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState, CSSProperties } from 'react' 2 | import classnames from 'classnames' 3 | 4 | const checked = ( 5 | 6 | 7 | 8 | ) 9 | const unchecked = 10 | const radioUnchecked = ( 11 | 12 | 13 | 14 | ) 15 | const radioChecked = ( 16 | 17 | 18 | 19 | ) 20 | 21 | export type MenuItemT = 22 | | { 23 | label?: string 24 | accelerator?: string 25 | type?: 'normal' 26 | enabled?: boolean 27 | visiable?: boolean 28 | checked?: boolean 29 | click: (menuItem: MenuItemT, event: React.MouseEvent) => void 30 | } 31 | | { 32 | type: 'separator' 33 | visiable?: boolean 34 | } 35 | | { 36 | label?: string 37 | accelerator?: string 38 | type: 'checkbox' 39 | enabled?: boolean 40 | visiable?: boolean 41 | checked?: boolean 42 | click: (menuItem: MenuItemT, event: React.MouseEvent) => void 43 | } 44 | | { 45 | label?: string 46 | accelerator?: string 47 | type: 'radio' 48 | enabled?: boolean 49 | visiable?: boolean 50 | checked?: boolean 51 | click: (menuItem: MenuItemT, event: React.MouseEvent) => void 52 | } 53 | 54 | export interface MenuListItemProps { 55 | mainIndex: number 56 | subIndex: number 57 | curItem: MenuItemT 58 | changeCheckState: (mainIndex: number, subIndex: number, check: boolean, isRadio?: boolean) => void 59 | style: CSSProperties 60 | } 61 | 62 | export const MenuListItem: React.FC = ({ 63 | mainIndex, 64 | subIndex, 65 | curItem, 66 | style, 67 | changeCheckState, 68 | }) => { 69 | const [selected, setSelected] = useState(false) 70 | const onMouseOver = () => setSelected(true) 71 | const onMouseLeave = () => setSelected(false) 72 | const handleClick = useCallback((e: React.MouseEvent) => { 73 | if (curItem.type === 'separator' || curItem.enabled === false) { 74 | e.stopPropagation() 75 | return 76 | } else if (curItem.type === 'checkbox') { 77 | e.persist() 78 | const nextCurItem = { 79 | ...curItem, 80 | checked: !curItem.checked, 81 | } 82 | curItem.click(nextCurItem, e) 83 | changeCheckState(mainIndex, subIndex, !curItem.checked) 84 | } else if (curItem.type === 'radio') { 85 | const nextCurItem = { 86 | ...curItem, 87 | checked: true, 88 | } 89 | curItem.click(nextCurItem, e) 90 | if (!curItem.checked) { 91 | changeCheckState(mainIndex, subIndex, true, true) 92 | } 93 | } else { 94 | e.persist() 95 | curItem.click(curItem, e) 96 | } 97 | }, []) 98 | 99 | if (curItem.visiable == false) { 100 | return
101 | } 102 | const listItemClass = classnames('list-item', { 103 | selected: selected && curItem.type !== 'separator' && curItem.enabled !== false, 104 | }) 105 | const menuItemClass = classnames('menu-item', { 106 | disabled: curItem.type !== 'separator' && curItem.enabled === false, 107 | }) 108 | const innerContent = 109 | curItem.type === 'separator' ? ( 110 |
111 | ) : ( 112 |
113 |
114 | {curItem.type === 'radio' 115 | ? curItem.checked 116 | ? radioChecked 117 | : radioUnchecked 118 | : curItem.checked && curItem.type === 'checkbox' 119 | ? checked 120 | : unchecked} 121 |
122 |
123 | {curItem.label} 124 |
125 | {curItem.accelerator &&
{curItem.accelerator}
} 126 |
127 | ) 128 | return ( 129 |
137 | {innerContent} 138 |
139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /assets/style.css: -------------------------------------------------------------------------------- 1 | #electron-app-title-bar { 2 | height: 28px; 3 | background: #24292e; 4 | border-bottom: 1px solid #000; 5 | -webkit-app-region: drag; 6 | flex-grow: 0; 7 | flex-shrink: 0; 8 | width: 100%; 9 | display: flex; 10 | flex-direction: row; 11 | color: #24292e; 12 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif; 13 | font-size: 12px; 14 | } 15 | 16 | /*resize handler*/ 17 | #electron-app-title-bar .resize-handle { 18 | position: absolute; 19 | top: 0px; 20 | left: 0px; 21 | -webkit-app-region: no-drag; 22 | } 23 | 24 | #electron-app-title-bar .resize-handle.resize-handle-left { 25 | width: 3px; 26 | height: 28px; 27 | } 28 | 29 | #electron-app-title-bar .resize-handle.resize-handle-top { 30 | width: 100%; 31 | height: 3px; 32 | } 33 | 34 | /*icon*/ 35 | #electron-app-title-bar img.icon { 36 | height: 16px; 37 | width: 16px; 38 | margin: 6px; 39 | } 40 | 41 | /*toolbar button*/ 42 | #electron-app-title-bar .toolbar-button { 43 | min-width: 0; 44 | position: relative; 45 | } 46 | 47 | #electron-app-title-bar .toolbar-button > button { 48 | -webkit-appearance: none; 49 | border: none; 50 | box-shadow: none; 51 | background: transparent; 52 | border-radius: 0; 53 | text-align: left; 54 | margin: 0; 55 | padding: 0; 56 | height: 100%; 57 | width: 100%; 58 | } 59 | 60 | #electron-app-title-bar .toolbar-button > button:active { 61 | box-shadow: none; 62 | } 63 | 64 | #electron-app-title-bar .toolbar-button > button:focus { 65 | background-color: #2f363d; 66 | outline-offset: -4px; 67 | border-color: black; 68 | box-shadow: none; 69 | } 70 | 71 | #electron-app-title-bar .toolbar-button > button:focus .progress { 72 | background: #444d56; 73 | } 74 | 75 | #electron-app-title-bar .toolbar-button > button:focus:not(.focus-ring) { 76 | outline: none; 77 | background-color: transparent; 78 | } 79 | 80 | #electron-app-title-bar .toolbar-button > button:focus:not(.focus-ring) .progress { 81 | background: #2f363d; 82 | } 83 | 84 | #electron-app-title-bar .toolbar-button > button:not(:disabled):hover { 85 | background-color: #2f363d; 86 | color: #fff; 87 | border-color: black; 88 | } 89 | 90 | #electron-app-title-bar .toolbar-button > button:not(:disabled):hover .description { 91 | color: #959da5; 92 | } 93 | 94 | #electron-app-title-bar .toolbar-button > button:not(:disabled):hover .progress { 95 | background: #444d56; 96 | } 97 | 98 | #electron-app-title-bar .toolbar-button > button { 99 | position: relative; 100 | display: flex; 101 | flex-direction: row; 102 | align-items: center; 103 | padding: 10px; 104 | margin: 0; 105 | overflow: hidden; 106 | background-color: transparent; 107 | color: #fff; 108 | border-right: 1px solid black; 109 | } 110 | 111 | #electron-app-title-bar .toolbar-button > button .icon { 112 | flex-shrink: 0; 113 | margin-right: 10px; 114 | position: relative; 115 | } 116 | 117 | #electron-app-title-bar .toolbar-button > button .dropdownArrow { 118 | width: 9px; 119 | height: 13px; 120 | flex-shrink: 0; 121 | position: relative; 122 | } 123 | 124 | #electron-app-title-bar .toolbar-button > button .text { 125 | display: flex; 126 | flex-direction: column; 127 | flex-grow: 1; 128 | min-width: 0; 129 | margin-right: 10px; 130 | position: relative; 131 | } 132 | 133 | #electron-app-title-bar .toolbar-button > button .title { 134 | font-weight: 600; 135 | position: relative; 136 | } 137 | 138 | #electron-app-title-bar .toolbar-button > button .description { 139 | color: #959da5; 140 | font-size: 11px; 141 | position: relative; 142 | } 143 | 144 | #electron-app-title-bar .toolbar-button > button .title, .toolbar-button > button .description { 145 | overflow: hidden; 146 | text-overflow: ellipsis; 147 | white-space: nowrap; 148 | } 149 | 150 | #electron-app-title-bar .toolbar-button > button .progress { 151 | position: absolute; 152 | top: 0px; 153 | left: 0px; 154 | width: 100%; 155 | height: 100%; 156 | background: #2f363d; 157 | transform-origin: left; 158 | pointer-events: none; 159 | transition: transform 0.3s cubic-bezier(0.23, 1, 0.32, 1); 160 | } 161 | 162 | #electron-app-title-bar .toolbar-button.has-progress > button:disabled { 163 | opacity: 1; 164 | } 165 | 166 | /*button component*/ 167 | #electron-app-title-bar .button-component { 168 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif; 169 | font-size: 12px; 170 | padding: 0 10px; 171 | border: 1px solid #e1e4e8; 172 | height: 25px; 173 | color: #24292e; 174 | background-color: #fafbfc; 175 | border-radius: 2px; 176 | } 177 | 178 | #electron-app-title-bar .button-component:not(:disabled):hover { 179 | border-color: #d1d5da; 180 | background-color: #fff; 181 | } 182 | 183 | #electron-app-title-bar .button-component:focus { 184 | background-color: #fff; 185 | border-color: #d1d5da; 186 | box-shadow: 0 0 0 1px rgba(225, 228, 232, 0.75); 187 | } 188 | 189 | #electron-app-title-bar .button-component:disabled { 190 | opacity: 0.6; 191 | } 192 | 193 | #electron-app-title-bar .button-component .octicon { 194 | vertical-align: middle; 195 | } 196 | 197 | #electron-app-title-bar .button-component[type='submit'] { 198 | background-color: #0366d6; 199 | color: #fff; 200 | border: 1px solid #0366d6; 201 | } 202 | 203 | #electron-app-title-bar .button-component[type='submit']:not(:disabled):hover { 204 | border-color: #0366d6; 205 | background-color: #0372ef; 206 | } 207 | 208 | #electron-app-title-bar .button-component[type='submit']:focus { 209 | border: 1px solid #005cc5; 210 | background-color: #0372ef; 211 | box-shadow: 0 0 0 2px rgba(3, 102, 214, 0.25); 212 | } 213 | 214 | 215 | /*dropdown*/ 216 | #electron-app-title-bar .toolbar-dropdown { 217 | min-width: 0; 218 | } 219 | 220 | #electron-app-title-bar .toolbar-dropdown > .toolbar-button { 221 | width: 100%; 222 | height: 100%; 223 | } 224 | 225 | #electron-app-title-bar .toolbar-dropdown.open > .toolbar-button > button { 226 | color: #24292e; 227 | background-color: #fff; 228 | border-color: #fff; 229 | } 230 | 231 | #electron-app-title-bar .toolbar-dropdown.open > .toolbar-button > button .description { 232 | color: #6a737d; 233 | } 234 | 235 | #electron-app-title-bar .toolbar-dropdown.open > .toolbar-button > button .progress { 236 | background-color: #e1e4e8; 237 | } 238 | 239 | #electron-app-title-bar .toolbar-dropdown:not(.open) .menu-item .menu-label { 240 | opacity: 0.6; 241 | } 242 | 243 | /*menubar*/ 244 | #electron-app-title-bar #app-menu-bar { 245 | display: flex; 246 | -webkit-app-region: no-drag; 247 | max-width: calc(100% - 163px); 248 | overflow: hidden; 249 | } 250 | 251 | #electron-app-title-bar #app-menu-bar .toolbar-button > button { 252 | padding: 0 10px; 253 | border: 0; 254 | } 255 | 256 | #electron-app-title-bar #app-menu-bar .toolbar-button > button .access-key.highlight { 257 | text-decoration: underline; 258 | } 259 | 260 | #electron-app-title-bar #app-menu-bar .toolbar-dropdown:not(.open) > .toolbar-button > button { 261 | color: #959da5; 262 | } 263 | 264 | #electron-app-title-bar #app-menu-bar .toolbar-dropdown:not(.open) > .toolbar-button > button:hover, #app-menu-bar .toolbar-dropdown:not(.open) > .toolbar-button > button:focus { 265 | color: #fff; 266 | } 267 | 268 | #electron-app-title-bar #app-menu-bar #foldout-container .foldout { 269 | background: transparent; 270 | pointer-events: none; 271 | } 272 | 273 | #electron-app-title-bar #app-menu-bar #foldout-container .foldout .menu-pane { 274 | background: #fff; 275 | pointer-events: all; 276 | } 277 | 278 | #electron-app-title-bar #foldout-container { 279 | z-index: 8; 280 | } 281 | 282 | #electron-app-title-bar #foldout-container .overlay { 283 | background: black; 284 | opacity: 0.4; 285 | height: 100%; 286 | overflow: hidden; 287 | } 288 | 289 | #electron-app-title-bar #foldout-container .overlay:focus { 290 | outline: none; 291 | border: none; 292 | box-shadow: none; 293 | } 294 | 295 | #electron-app-title-bar #foldout-container .foldout { 296 | background: #fff; 297 | color: #24292e; 298 | } 299 | 300 | /*menupane*/ 301 | #electron-app-title-bar #app-menu-foldout { 302 | height: 100%; 303 | display: flex; 304 | } 305 | 306 | #electron-app-title-bar .list { 307 | flex-grow: 1; 308 | height: 100%; 309 | min-width: 0; 310 | overflow: hidden; 311 | } 312 | 313 | #electron-app-title-bar .list .ReactVirtualized__Grid { 314 | background: #fff; 315 | } 316 | 317 | #electron-app-title-bar .list-item { 318 | display: flex; 319 | flex-direction: row; 320 | align-items: center; 321 | width: 100%; 322 | height: 100%; 323 | } 324 | 325 | #electron-app-title-bar .list-item.selected { 326 | color: #fff; 327 | background-color: #0366d6; 328 | } 329 | 330 | #electron-app-title-bar .list-item.selected:focus { 331 | color: #fff; 332 | background-color: #0366d6; 333 | } 334 | 335 | #electron-app-title-bar .list-item:focus { 336 | outline: none; 337 | } 338 | 339 | #electron-app-title-bar .menu-pane { 340 | height: 100%; 341 | width: 240px; 342 | } 343 | 344 | #electron-app-title-bar .menu-pane:not(:first-child) { 345 | border-left: 1px solid #e1e4e8; 346 | } 347 | 348 | #electron-app-title-bar .menu-pane:not(:last-child) .list-item.selected { 349 | color: #fff; 350 | background-color: #0366d6; 351 | } 352 | 353 | #electron-app-title-bar .menu-pane .ReactVirtualized__Grid:focus { 354 | outline: none; 355 | } 356 | 357 | #electron-app-title-bar .menu-pane .menu-item { 358 | display: flex; 359 | align-items: center; 360 | height: 100%; 361 | width: 100%; 362 | min-width: 0; 363 | } 364 | 365 | #electron-app-title-bar .menu-pane .menu-item.disabled { 366 | opacity: 0.3; 367 | } 368 | 369 | #electron-app-title-bar .menu-pane .menu-item .menu-label { 370 | flex-grow: 1; 371 | margin-left: 10px; 372 | margin-right: 10px; 373 | overflow: hidden; 374 | text-overflow: ellipsis; 375 | white-space: nowrap; 376 | } 377 | 378 | #electron-app-title-bar .menu-pane .menu-item .submenu-arrow { 379 | flex-shrink: 0; 380 | opacity: 0.7; 381 | height: 12px; 382 | margin-right: 10px; 383 | } 384 | 385 | #electron-app-title-bar .menu-pane .menu-item .accelerator { 386 | flex-shrink: 0; 387 | margin-right: 10px; 388 | color: #6a737d; 389 | } 390 | 391 | #electron-app-title-bar .list-item.selected .accelerator { 392 | color: #fff; 393 | } 394 | 395 | #electron-app-title-bar .menu-pane .menu-item.checked .menu-label { 396 | margin-left: 0; 397 | } 398 | 399 | #electron-app-title-bar .menu-pane .menu-item.checked .icon { 400 | flex-grow: 0; 401 | margin: 2px 0; 402 | } 403 | 404 | #electron-app-title-bar .menu-pane .menu-item .access-key.highlight { 405 | text-decoration: underline; 406 | } 407 | 408 | #electron-app-title-bar .menu-pane hr { 409 | display: block; 410 | width: 100%; 411 | border: none; 412 | height: 1px; 413 | border-bottom: 1px solid #e1e4e8; 414 | } 415 | 416 | #electron-app-title-bar .menu-pane .menu-endblock { 417 | height: 5px; 418 | } 419 | 420 | #electron-app-title-bar .status-icon { 421 | margin-left: 10px; 422 | width: 12px; 423 | height: 12px; 424 | } 425 | 426 | #electron-app-title-bar .status-icon svg { 427 | width: 100%; 428 | height: 100%; 429 | } 430 | /*window-controls*/ 431 | #electron-app-title-bar .window-controls { 432 | flex-grow: 0; 433 | flex-shrink: 0; 434 | margin-left: auto; 435 | height: 100%; 436 | } 437 | 438 | #electron-app-title-bar .window-controls button { 439 | -webkit-app-region: no-drag; 440 | display: inline-block; 441 | position: relative; 442 | width: 45px; 443 | height: 100%; 444 | padding: 0; 445 | margin: 0; 446 | overflow: hidden; 447 | border: none; 448 | box-shadow: none; 449 | border-radius: 0; 450 | color: #a0a0a0; 451 | background-color: transparent; 452 | transition: background-color 0.25s ease; 453 | } 454 | 455 | #electron-app-title-bar .window-controls button:not(:disabled):focus { 456 | outline: none; 457 | } 458 | 459 | #electron-app-title-bar .window-controls button:not(:disabled):hover { 460 | background-color: #888; 461 | color: #fff; 462 | } 463 | 464 | #electron-app-title-bar .window-controls button:not(:disabled):hover:active { 465 | background-color: #666; 466 | transition: none; 467 | } 468 | 469 | #electron-app-title-bar .window-controls button.window-close:not(:disabled):hover { 470 | background-color: #e81123; 471 | color: #fff; 472 | } 473 | 474 | #electron-app-title-bar .window-controls button.window-close:not(:disabled):hover:active { 475 | background-color: #bf0f1d; 476 | transition: none; 477 | } 478 | 479 | #electron-app-title-bar .window-controls button svg { 480 | fill: currentColor; 481 | } 482 | 483 | #electron-app-title-bar :not(input):not(textarea), :not(input):not(textarea)::after, :not(input):not(textarea)::before { 484 | -webkit-user-select: none; 485 | user-select: none; 486 | cursor: default; 487 | } 488 | --------------------------------------------------------------------------------