├── .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 |
100 | )
101 | }
102 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # electron-react-titlebar
2 | A github desktop style title bar component for electron.
3 |
4 | 
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 |
8 | )
9 | const unchecked =
10 | const radioUnchecked = (
11 |
14 | )
15 | const radioChecked = (
16 |
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 |
--------------------------------------------------------------------------------