├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── release-tag.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.yaml ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── package.json ├── packages ├── contextmenu │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── rollup.config.ts │ ├── src │ │ ├── common.ts │ │ ├── constants.ts │ │ ├── main.ts │ │ ├── renderer.ts │ │ └── types.ts │ └── tsconfig.json ├── core │ ├── CHANGELOG.md │ ├── README.md │ ├── globals.d.ts │ ├── package.json │ ├── rollup.config.ts │ ├── src │ │ ├── global.ts │ │ ├── main.ts │ │ └── preload.ts │ └── tsconfig.json ├── notification │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── rollup.config.ts │ ├── screenshots │ │ ├── customization.png │ │ └── default.png │ ├── src │ │ ├── common.ts │ │ ├── constants.ts │ │ ├── icon.svg │ │ ├── index.html │ │ ├── main.ts │ │ ├── preload.ts │ │ ├── renderer.ts │ │ └── types.ts │ └── tsconfig.json ├── playground │ ├── .env.preload │ ├── README.md │ ├── electron.vite.config.ts │ ├── package.json │ ├── src │ │ ├── main │ │ │ ├── env.d.ts │ │ │ └── index.ts │ │ ├── preload │ │ │ └── index.ts │ │ └── renderer │ │ │ ├── index.html │ │ │ ├── public │ │ │ ├── moon.svg │ │ │ └── sun.svg │ │ │ └── src │ │ │ ├── env.d.ts │ │ │ └── index.ts │ └── tsconfig.json ├── titlebar │ ├── CHANGELOG.md │ ├── README.md │ ├── custom-elements.json │ ├── package.json │ ├── rollup.config.ts │ ├── screenshots │ │ ├── mac-light.png │ │ └── win32-dark.png │ ├── src │ │ ├── common.ts │ │ ├── constants.ts │ │ ├── main.ts │ │ └── renderer.ts │ └── tsconfig.json └── toast │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── rollup.config.ts │ ├── screenshots │ └── toast.png │ ├── src │ ├── common.ts │ ├── constants.ts │ ├── main.ts │ ├── renderer.ts │ └── types.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.base.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | commonjs: true, 5 | es6: true, 6 | node: true 7 | }, 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | sourceType: 'module', 11 | ecmaVersion: 2022 12 | }, 13 | plugins: ['@typescript-eslint'], 14 | extends: [ 15 | 'eslint:recommended', 16 | 'plugin:@typescript-eslint/recommended', 17 | 'plugin:@typescript-eslint/eslint-recommended', 18 | 'plugin:prettier/recommended' 19 | ], 20 | rules: { 21 | 'prettier/prettier': 'warn', 22 | '@typescript-eslint/ban-ts-comment': [ 23 | 'error', 24 | { 'ts-ignore': 'allow-with-description' } 25 | ], 26 | '@typescript-eslint/explicit-function-return-type': 'error', 27 | '@typescript-eslint/explicit-module-boundary-types': 'off', 28 | '@typescript-eslint/no-empty-function': [ 29 | 'error', 30 | { allow: ['arrowFunctions'] } 31 | ], 32 | '@typescript-eslint/no-explicit-any': 'error', 33 | '@typescript-eslint/no-non-null-assertion': 'off', 34 | '@typescript-eslint/no-var-requires': 'off' 35 | }, 36 | overrides: [ 37 | { 38 | files: ['*.js', '*.mjs'], 39 | rules: { 40 | '@typescript-eslint/explicit-function-return-type': 'off' 41 | } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/release-tag.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'core*' 5 | - 'titlebar*' 6 | - 'contextmenu*' 7 | - 'notification*' 8 | - 'toast*' 9 | 10 | name: Create Release 11 | 12 | jobs: 13 | build: 14 | name: Create Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@master 19 | - name: Get package name for tag 20 | id: tag 21 | run: | 22 | pkgName=${GITHUB_REF_NAME%@*} 23 | echo "::set-output name=pkgName::$pkgName" 24 | - name: Create Release for Tag 25 | id: release_tag 26 | uses: actions/create-release@v1 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | with: 30 | tag_name: ${{ github.ref }} 31 | release_name: ${{ github.ref }} 32 | body: | 33 | Please refer to [CHANGELOG.md](https://github.com/alex8088/electron-uikit/blob/${{ github.ref_name }}/packages/${{ steps.tag.outputs.pkgName }}/CHANGELOG.md) for details. 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | auto-install-peers=false 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | packages/**/dist 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 80 4 | trailingComma: none 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "[typescript]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "[javascript]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "[json]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "html.customData": ["./packages/titlebar/custom-elements.json"] 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-present, Alex.Wei 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

electron-uikit

2 | 3 |

UI kit for Electron. e.g. titlebar, contextmenu, notification, etc.

4 | 5 |

6 | electron 7 | license 8 |

9 | 10 | ## Packages 11 | 12 | | Package | Description | Version | 13 | | ----------------------------------------------------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | 14 | | [@electron-uikit/core](packages/core) | Electron UI kit core | [![core version](https://img.shields.io/npm/v/@electron-uikit/core.svg?label=%20)](packages/core/CHANGELOG.md) | 15 | | [@electron-uikit/contextmenu](packages/contextmenu) | Context menu | [![contextmenu version](https://img.shields.io/npm/v/@electron-uikit/contextmenu.svg?label=%20)](packages/contextmenu/CHANGELOG.md) | 16 | | [@electron-uikit/notification](packages/notification) | Notification | [![notification version](https://img.shields.io/npm/v/@electron-uikit/notification.svg?label=%20)](packages/notification/CHANGELOG.md) | 17 | | [@electron-uikit/titlebar](packages/titlebar) | Title bar web component | [![titlebar version](https://img.shields.io/npm/v/@electron-uikit/titlebar.svg?label=%20)](packages/titlebar/CHANGELOG.md) | 18 | | [@electron-uikit/toast](packages/toast) | Toast | [![toast version](https://img.shields.io/npm/v/@electron-uikit/toast.svg?label=%20)](packages/toast/CHANGELOG.md) | 19 | 20 | ## License 21 | 22 | [MIT](./LICENSE) 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-uikit", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "UI kit for Electron. e.g. titlebar, contextmenu, notification, etc.", 6 | "license": "MIT", 7 | "author": "Alex Wei", 8 | "scripts": { 9 | "preinstall": "npx only-allow pnpm", 10 | "format": "prettier --write .", 11 | "lint": "eslint --ext .ts packages/*/src/**", 12 | "typecheck": "tsc --noEmit", 13 | "play": "pnpm run -C packages/playground dev", 14 | "play:preload": "pnpm run -C packages/playground dev:preload", 15 | "build:core": "pnpm run -C packages/core build", 16 | "build:contextmenu": "pnpm run -C packages/contextmenu build", 17 | "build:notification": "pnpm run -C packages/notification build", 18 | "build:titlebar": "pnpm run -C packages/titlebar build", 19 | "build:toast": "pnpm run -C packages/toast build" 20 | }, 21 | "devDependencies": { 22 | "@rollup/plugin-commonjs": "^26.0.1", 23 | "@rollup/plugin-node-resolve": "^15.2.3", 24 | "@rollup/plugin-typescript": "^11.1.6", 25 | "@types/node": "^18.19.34", 26 | "@typescript-eslint/eslint-plugin": "^7.13.0", 27 | "@typescript-eslint/parser": "^7.13.0", 28 | "electron": "^35.0.2", 29 | "eslint": "^8.57.0", 30 | "eslint-config-prettier": "^9.1.0", 31 | "eslint-plugin-prettier": "^5.1.3", 32 | "prettier": "^3.3.1", 33 | "rollup": "^4.18.0", 34 | "rollup-plugin-dts": "^6.1.1", 35 | "rollup-plugin-rm": "^1.0.2", 36 | "typescript": "^5.4.5" 37 | }, 38 | "pnpm": { 39 | "peerDependencyRules": { 40 | "ignoreMissing": [ 41 | "electron" 42 | ] 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/contextmenu/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v1.0.0 (_2024-06-23_) 2 | 3 | -`@electron-uikit/contextmenu` init 4 | -------------------------------------------------------------------------------- /packages/contextmenu/README.md: -------------------------------------------------------------------------------- 1 | # @electron-uikit/contextmenu 2 | 3 | ![contextmenu version](https://img.shields.io/npm/v/@electron-uikit/contextmenu.svg?color=orange&label=version) 4 | 5 | Context menu for Electron app. Inspired by VS Code. 6 | 7 | You can use the `Menu` class to create context menu in Electron, but it can only be created in the main process. But usually the context menu is triggered in the renderer process, which leads to code logic fragmentation, difficulty in reading, and additional IPC is required to respond to context menu operation processing results. UIKit context menu aims to solve these problems and provide a better DX. 8 | 9 | You can easily use the context menu in the renderer process and use the same way as Electron Menu. 10 | 11 | ## Usage 12 | 13 | ### Install 14 | 15 | ```sh 16 | npm i @electron-uikit/core @electron-uikit/contextmenu 17 | ``` 18 | 19 | ### Get Started 20 | 21 | 1. Exposes the UI Kit APIs for components. See [@electron-uikit/core](https://github.com/alex8088/electron-uikit/tree/main/packages/core) guide for more details. 22 | 23 | You can expose it in the specified preload script: 24 | 25 | ```js 26 | import { exposeUIKit } from '@electron-uikit/core/preload' 27 | 28 | exposeUIKit() 29 | ``` 30 | 31 | Or, you can expose it globally in the main process for all renderer processes: 32 | 33 | ```js 34 | import { useUIKit } from '@electron-uikit/core/main' 35 | 36 | useUIKit() 37 | ``` 38 | 39 | > [!NOTE] 40 | > If you are using [@electron-toolkit/preload](https://github.com/alex8088/electron-toolkit/tree/master/packages/preload) to expose Electron APIs, there is no need to use this module, because `core` is also an export of it. 41 | 42 | 2. Register a listener in the main process, so that you can use it in the renderer process. 43 | 44 | ```js 45 | import { app } from 'electron' 46 | import { registerContextMenuListener } from '@electron-uikit/contextmenu' 47 | 48 | app.whenReady().then(() => { 49 | // Register context menu IPC listeners 50 | registerContextMenuListener() 51 | 52 | // ... 53 | }) 54 | ``` 55 | 56 | 3. Use the context menu in the renderer process. 57 | 58 | ```js 59 | import { Menu, MenuItem } from '@electron-uikit/contextmenu/renderer' 60 | 61 | document.body.addEventListener('contextmenu', (e) => { 62 | e.preventDefault() 63 | 64 | const menu = new Menu() 65 | menu.append( 66 | new MenuItem({ 67 | type: 'checkbox', 68 | label: 'Menu Item One', 69 | checked: true, 70 | click: (): void => { 71 | console.log('menu item one') 72 | } 73 | }) 74 | ) 75 | menu.append(new MenuItem({ type: 'separator' })) 76 | menu.append( 77 | new MenuItem({ 78 | label: 'Menu Item Two', 79 | click: (): void => { 80 | console.log('menu item two') 81 | } 82 | }) 83 | ) 84 | menu.popup() 85 | }) 86 | ``` 87 | -------------------------------------------------------------------------------- /packages/contextmenu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electron-uikit/contextmenu", 3 | "version": "1.0.0", 4 | "description": "Context menu for Electron app.", 5 | "main": "dist/main.cjs", 6 | "module": "dist/main.mjs", 7 | "types": "dist/main.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/main.d.ts", 11 | "import": "./dist/main.mjs", 12 | "require": "./dist/main.cjs" 13 | }, 14 | "./main": { 15 | "types": "./dist/main.d.ts", 16 | "import": "./dist/main.mjs", 17 | "require": "./dist/main.cjs" 18 | }, 19 | "./renderer": { 20 | "types": "./dist/renderer.d.ts", 21 | "import": "./dist/renderer.mjs" 22 | } 23 | }, 24 | "typesVersions": { 25 | "*": { 26 | "main": [ 27 | "./dist/main.d.ts" 28 | ], 29 | "renderer": [ 30 | "./dist/renderer.d.ts" 31 | ] 32 | } 33 | }, 34 | "files": [ 35 | "dist" 36 | ], 37 | "author": "Alex Wei", 38 | "license": "MIT", 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/alex8088/electron-uikit.git", 42 | "directory": "packages/contextmenu" 43 | }, 44 | "bugs": { 45 | "url": "https://github.com/alex8088/electron-uikit/issues" 46 | }, 47 | "homepage": "https://github.com/alex8088/electron-uikit/tree/master/packages/contextmenu#readme", 48 | "keywords": [ 49 | "electron", 50 | "contextmenu" 51 | ], 52 | "scripts": { 53 | "build": "rollup -c rollup.config.ts --configPlugin typescript" 54 | }, 55 | "peerDependencies": { 56 | "@electron-uikit/core": "*", 57 | "electron": ">=15.0.0" 58 | }, 59 | "peerDependenciesMeta": { 60 | "@electron-uikit/core": { 61 | "optional": true 62 | } 63 | }, 64 | "devDependencies": { 65 | "@electron-uikit/core": "workspace:^" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/contextmenu/rollup.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | import { defineConfig } from 'rollup' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import commonjs from '@rollup/plugin-commonjs' 5 | import ts from '@rollup/plugin-typescript' 6 | import dts from 'rollup-plugin-dts' 7 | import rm from 'rollup-plugin-rm' 8 | 9 | export default defineConfig([ 10 | { 11 | input: ['src/main.ts'], 12 | output: [ 13 | { 14 | entryFileNames: '[name].cjs', 15 | chunkFileNames: 'chunks/lib-[hash].cjs', 16 | format: 'cjs', 17 | dir: 'dist' 18 | }, 19 | { 20 | entryFileNames: '[name].mjs', 21 | chunkFileNames: 'chunks/lib-[hash].mjs', 22 | format: 'es', 23 | dir: 'dist' 24 | } 25 | ], 26 | external: ['electron'], 27 | plugins: [ 28 | resolve(), 29 | commonjs(), 30 | ts({ 31 | compilerOptions: { 32 | rootDir: 'src', 33 | declaration: true, 34 | outDir: 'dist/types' 35 | } 36 | }), 37 | rm('dist', 'buildStart') 38 | ] 39 | }, 40 | { 41 | input: ['src/renderer.ts'], 42 | output: [ 43 | { file: './dist/renderer.mjs', format: 'es' }, 44 | { name: 'renderer', file: './dist/renderer.js', format: 'iife' } 45 | ], 46 | plugins: [ 47 | ts({ 48 | compilerOptions: { 49 | rootDir: 'src', 50 | declaration: true, 51 | outDir: 'dist/types' 52 | } 53 | }) 54 | ] 55 | }, 56 | { 57 | input: ['dist/types/main.d.ts'], 58 | output: [{ file: './dist/main.d.ts', format: 'es' }], 59 | plugins: [dts()], 60 | external: ['electron'] 61 | }, 62 | { 63 | input: ['dist/types/renderer.d.ts'], 64 | output: [{ file: './dist/renderer.d.ts', format: 'es' }], 65 | plugins: [dts(), rm('dist/types', 'buildEnd')] 66 | } 67 | ]) 68 | -------------------------------------------------------------------------------- /packages/contextmenu/src/common.ts: -------------------------------------------------------------------------------- 1 | import type { UIKitAPI } from '@electron-uikit/core' 2 | 3 | export const core = ((globalThis || window).uikit || 4 | (globalThis || window).electron) as UIKitAPI 5 | -------------------------------------------------------------------------------- /packages/contextmenu/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const CONTEXT_MENU_CHANNEL = 'uikit:contextmenu' 2 | export const CONTEXT_MENU_CLOSE_CHANNEL = 'uikit:onCloseContextmenu' 3 | -------------------------------------------------------------------------------- /packages/contextmenu/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain, IpcMainEvent, Menu, MenuItem, BrowserWindow } from 'electron' 2 | import { CONTEXT_MENU_CHANNEL, CONTEXT_MENU_CLOSE_CHANNEL } from './constants' 3 | 4 | import type { SerializableContextMenuItem, PopupOptions } from './types' 5 | 6 | /** 7 | * Register the context menu IPC listener for use by the renderer. 8 | */ 9 | export function registerContextMenuListener(): void { 10 | if (ipcMain.eventNames().some((ename) => ename === CONTEXT_MENU_CHANNEL)) { 11 | return 12 | } 13 | 14 | ipcMain.on( 15 | CONTEXT_MENU_CHANNEL, 16 | ( 17 | e, 18 | contextMenuId: number, 19 | items: SerializableContextMenuItem[], 20 | onClickChannel: string, 21 | options?: PopupOptions 22 | ) => { 23 | const menu = createMenu(e, onClickChannel, items) 24 | 25 | menu.popup({ 26 | window: BrowserWindow.fromWebContents(e.sender) || undefined, 27 | x: options ? options.x : undefined, 28 | y: options ? options.y : undefined, 29 | positioningItem: options ? options.positioningItem : undefined, 30 | callback: () => { 31 | if (menu) { 32 | e.sender.send(CONTEXT_MENU_CLOSE_CHANNEL, contextMenuId) 33 | } 34 | } 35 | }) 36 | } 37 | ) 38 | } 39 | 40 | function createMenu( 41 | event: IpcMainEvent, 42 | onClickChannel: string, 43 | items: SerializableContextMenuItem[] 44 | ): Menu { 45 | const menu = new Menu() 46 | 47 | items.forEach((item) => { 48 | let menuItem: MenuItem 49 | 50 | // Separator 51 | if (item.type === 'separator') { 52 | menuItem = new MenuItem({ 53 | type: item.type 54 | }) 55 | } 56 | 57 | // Sub Menu 58 | else if (Array.isArray(item.submenu)) { 59 | menuItem = new MenuItem({ 60 | submenu: createMenu(event, onClickChannel, item.submenu), 61 | label: item.label 62 | }) 63 | } 64 | 65 | // Normal Menu Item 66 | else { 67 | menuItem = new MenuItem({ 68 | label: item.label, 69 | type: item.type, 70 | accelerator: item.accelerator, 71 | checked: item.checked, 72 | enabled: item.enabled, 73 | visible: item.visible, 74 | click: (_menuItem, _win, contextmenuEvent): void => 75 | event.sender.send(onClickChannel, item.id, contextmenuEvent) 76 | }) 77 | } 78 | 79 | menu.append(menuItem) 80 | }) 81 | 82 | return menu 83 | } 84 | -------------------------------------------------------------------------------- /packages/contextmenu/src/renderer.ts: -------------------------------------------------------------------------------- 1 | import { core } from './common' 2 | import { CONTEXT_MENU_CHANNEL, CONTEXT_MENU_CLOSE_CHANNEL } from './constants' 3 | import type { 4 | ContextMenuItem, 5 | ContextMenuEvent, 6 | SerializableContextMenuItem, 7 | PopupOptions 8 | } from './types' 9 | 10 | export type { ContextMenuItem, ContextMenuEvent, PopupOptions } from './types' 11 | 12 | let contextMenuIdPool = 0 13 | 14 | /** 15 | * Pops up a context menu in the `BrowserWindow`. 16 | * @param items Menu items 17 | * @param options Popup options 18 | * @param onHide Called when menu is closed. 19 | */ 20 | export function popup( 21 | items: ContextMenuItem[], 22 | options?: PopupOptions, 23 | onHide?: () => void 24 | ): void { 25 | const processedItems: ContextMenuItem[] = [] 26 | 27 | const contextMenuId = contextMenuIdPool++ 28 | const onClickChannel = `uikit:onContextMenu${contextMenuId}` 29 | const onClickChannelHandler = ( 30 | _event: unknown, 31 | itemId: number, 32 | context: ContextMenuEvent 33 | ): void => { 34 | const item = processedItems[itemId] 35 | item.click?.(context) 36 | } 37 | 38 | const removeListener = core.ipcRenderer.once( 39 | onClickChannel, 40 | onClickChannelHandler 41 | ) 42 | core.ipcRenderer.once( 43 | CONTEXT_MENU_CLOSE_CHANNEL, 44 | (_event: unknown, closedContextMenuId: number) => { 45 | if (closedContextMenuId !== contextMenuId) { 46 | return 47 | } 48 | 49 | removeListener() 50 | 51 | onHide?.() 52 | } 53 | ) 54 | 55 | core.ipcRenderer.send( 56 | CONTEXT_MENU_CHANNEL, 57 | contextMenuId, 58 | items.map((item) => createItem(item, processedItems)), 59 | onClickChannel, 60 | options 61 | ) 62 | } 63 | 64 | function createItem( 65 | item: ContextMenuItem, 66 | processedItems: ContextMenuItem[] 67 | ): SerializableContextMenuItem { 68 | const serializableItem: SerializableContextMenuItem = { 69 | id: processedItems.length, 70 | label: item.label, 71 | type: item.type, 72 | accelerator: item.accelerator, 73 | checked: item.checked, 74 | enabled: typeof item.enabled === 'boolean' ? item.enabled : true, 75 | visible: typeof item.visible === 'boolean' ? item.visible : true 76 | } 77 | 78 | processedItems.push(item) 79 | 80 | // Submenu 81 | if (Array.isArray(item.submenu)) { 82 | serializableItem.submenu = item.submenu.map((submenuItem) => 83 | createItem(submenuItem, processedItems) 84 | ) 85 | } 86 | 87 | return serializableItem 88 | } 89 | 90 | export type MenuItemOptions = ContextMenuItem 91 | 92 | export class MenuItem { 93 | constructor(public options: MenuItemOptions) {} 94 | } 95 | 96 | export class Menu { 97 | items: ContextMenuItem[] = [] 98 | 99 | /** 100 | * Appends the `menuItem` to the menu. 101 | */ 102 | append(item: MenuItem): void { 103 | this.items.push(item.options) 104 | } 105 | 106 | /** 107 | * Pops up this menu as a context menu in the `BrowserWindow`. 108 | */ 109 | popup(options?: PopupOptions, onHide?: () => void): void { 110 | popup(this.items, options, onHide) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /packages/contextmenu/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface CommonContextMenuItem { 2 | /** 3 | * A `string` indicating the item's visible label. 4 | */ 5 | label?: string 6 | /** 7 | * A `string` indicating the type of the item. Can be `normal`, `separator`, 8 | * `submenu`, `checkbox` or `radio`. 9 | */ 10 | type?: 'normal' | 'separator' | 'submenu' | 'checkbox' | 'radio' 11 | /** 12 | * An `Accelerator` (optional) indicating the item's accelerator, if set. 13 | */ 14 | accelerator?: string 15 | /** 16 | * A `boolean` indicating whether the item is enabled. 17 | */ 18 | enabled?: boolean 19 | /** 20 | * A `boolean` indicating whether the item is visible. 21 | */ 22 | visible?: boolean 23 | /** 24 | * A `boolean` indicating whether the item is checked. 25 | * 26 | * A `checkbox` menu item will toggle the `checked` property on and off when 27 | * selected. 28 | * 29 | * A `radio` menu item will turn on its `checked` property when clicked, and will 30 | * turn off that property for all adjacent items in the same menu. 31 | * 32 | * You can add a `click` function for additional behavior. 33 | */ 34 | checked?: boolean 35 | } 36 | 37 | export interface SerializableContextMenuItem extends CommonContextMenuItem { 38 | id: number 39 | submenu?: SerializableContextMenuItem[] 40 | } 41 | 42 | export interface ContextMenuItem extends CommonContextMenuItem { 43 | /** 44 | * A `Function` that is fired when the MenuItem receives a click event. 45 | */ 46 | click?: (event: ContextMenuEvent) => void 47 | /** 48 | * A `Menu` (optional) containing the menu item's submenu, if present. 49 | */ 50 | submenu?: ContextMenuItem[] 51 | } 52 | 53 | export interface ContextMenuEvent { 54 | shiftKey?: boolean 55 | ctrlKey?: boolean 56 | altKey?: boolean 57 | metaKey?: boolean 58 | } 59 | 60 | export interface PopupOptions { 61 | /** 62 | * Default is the current mouse cursor position. Must be declared if `y` is 63 | * declared. 64 | */ 65 | x?: number 66 | /** 67 | * Default is the current mouse cursor position. Must be declared if `x` is 68 | * declared. 69 | */ 70 | y?: number 71 | /** 72 | * The index of the menu item to be positioned under the mouse cursor at the 73 | * specified coordinates. Default is -1. 74 | * 75 | * @platform darwin 76 | */ 77 | positioningItem?: number 78 | } 79 | -------------------------------------------------------------------------------- /packages/contextmenu/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v1.1.0 (_2024-03-16_) 2 | 3 | - fix(core): support new registering preload script menthod of Electron 35 ([#4](https://github.com/alex8088/electron-uikit/issues/4)) 4 | 5 | ### v1.0.0 (_2024-06-23_) 6 | 7 | -`@electron-uikit/core` init 8 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @electron-uikit/core 2 | 3 | Electron UI kit core. Exposes the UI Kit APIs for communicating between main and renderer processes. 4 | 5 | ## Usage 6 | 7 | ### Install 8 | 9 | ```sh 10 | npm i @electron-uikit/core 11 | ``` 12 | 13 | ### Get Started 14 | 15 | You can expose it in the specified preload script: 16 | 17 | ```js 18 | import { exposeUIKit } from '@electron-uikit/core/preload' 19 | 20 | exposeUIKit() 21 | ``` 22 | 23 | Or, you can expose it globally in the main process for all renderer processes: 24 | 25 | ```js 26 | import { useUIKit } from '@electron-uikit/core/main' 27 | 28 | useUIKit() 29 | ``` 30 | 31 | > [!NOTE] 32 | > If you are using [@electron-toolkit/preload](https://github.com/alex8088/electron-toolkit/tree/master/packages/preload) to expose Electron APIs, there is no need to use this module, because `core` is also an export of it. 33 | -------------------------------------------------------------------------------- /packages/core/globals.d.ts: -------------------------------------------------------------------------------- 1 | import { UIKitAPI } from '.' 2 | 3 | declare global { 4 | interface Window { 5 | uikit: UIKitAPI 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electron-uikit/core", 3 | "version": "1.1.0", 4 | "description": "Electron UI kit core.", 5 | "main": "dist/main.cjs", 6 | "module": "dist/main.mjs", 7 | "types": "dist/main.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/main.d.ts", 11 | "import": "./dist/main.mjs", 12 | "require": "./dist/main.cjs" 13 | }, 14 | "./main": { 15 | "types": "./dist/main.d.ts", 16 | "import": "./dist/main.mjs", 17 | "require": "./dist/main.cjs" 18 | }, 19 | "./preload": { 20 | "types": "./dist/preload.d.ts", 21 | "import": "./dist/preload.mjs", 22 | "require": "./dist/preload.cjs" 23 | }, 24 | "./globals": { 25 | "types": "./globals.d.ts" 26 | } 27 | }, 28 | "typesVersions": { 29 | "*": { 30 | "main": [ 31 | "./dist/main.d.ts" 32 | ], 33 | "preload": [ 34 | "./dist/preload.d.ts" 35 | ] 36 | } 37 | }, 38 | "files": [ 39 | "dist", 40 | "globals.d.ts" 41 | ], 42 | "author": "Alex Wei", 43 | "license": "MIT", 44 | "repository": { 45 | "type": "git", 46 | "url": "git+https://github.com/alex8088/electron-uikit.git", 47 | "directory": "packages/core" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/alex8088/electron-uikit/issues" 51 | }, 52 | "homepage": "https://github.com/alex8088/electron-uikit/tree/master/packages/core#readme", 53 | "keywords": [ 54 | "electron", 55 | "ui", 56 | "titlebar", 57 | "contextmenu", 58 | "notification" 59 | ], 60 | "scripts": { 61 | "build": "rollup -c rollup.config.ts --configPlugin typescript" 62 | }, 63 | "peerDependencies": { 64 | "electron": ">=15.0.0" 65 | }, 66 | "devDependencies": { 67 | "@electron-toolkit/preload": "^3.0.1" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/core/rollup.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | import { defineConfig } from 'rollup' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import commonjs from '@rollup/plugin-commonjs' 5 | import ts from '@rollup/plugin-typescript' 6 | import dts from 'rollup-plugin-dts' 7 | import rm from 'rollup-plugin-rm' 8 | 9 | export default defineConfig([ 10 | { 11 | input: ['src/global.ts'], 12 | output: [ 13 | { 14 | entryFileNames: 'uikit-preload.cjs', 15 | format: 'cjs', 16 | dir: 'dist' 17 | }, 18 | { 19 | entryFileNames: 'uikit-preload.mjs', 20 | format: 'es', 21 | dir: 'dist' 22 | } 23 | ], 24 | external: ['electron'], 25 | plugins: [rm('dist', 'buildStart'), resolve(), commonjs()] 26 | }, 27 | { 28 | input: ['src/main.ts', 'src/preload.ts'], 29 | output: [ 30 | { 31 | entryFileNames: '[name].cjs', 32 | chunkFileNames: 'chunks/lib-[hash].cjs', 33 | format: 'cjs', 34 | dir: 'dist' 35 | }, 36 | { 37 | entryFileNames: '[name].mjs', 38 | chunkFileNames: 'chunks/lib-[hash].mjs', 39 | format: 'es', 40 | dir: 'dist' 41 | } 42 | ], 43 | external: ['electron'], 44 | plugins: [ 45 | resolve(), 46 | commonjs(), 47 | ts({ 48 | compilerOptions: { 49 | rootDir: 'src', 50 | declaration: true, 51 | outDir: 'dist/types' 52 | } 53 | }), 54 | { 55 | name: 'import-meta-url', 56 | resolveImportMeta(property, { format }) { 57 | if (property === 'url' && format === 'cjs') { 58 | return `require("url").pathToFileURL(__filename).href` 59 | } 60 | return null 61 | } 62 | } 63 | ] 64 | }, 65 | { 66 | input: ['dist/types/main.d.ts'], 67 | output: [{ file: './dist/main.d.ts', format: 'es' }], 68 | plugins: [dts({ respectExternal: true })], 69 | external: ['electron'] 70 | }, 71 | { 72 | input: ['dist/types/preload.d.ts'], 73 | output: [{ file: './dist/preload.d.ts', format: 'es' }], 74 | plugins: [dts(), rm('dist/types', 'buildEnd')] 75 | } 76 | ]) 77 | -------------------------------------------------------------------------------- /packages/core/src/global.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from 'electron' 2 | import { electronAPI } from '@electron-toolkit/preload' 3 | 4 | if (process.contextIsolated) { 5 | try { 6 | contextBridge.exposeInMainWorld('uikit', electronAPI) 7 | } catch (error) { 8 | console.error(error) 9 | } 10 | } else { 11 | // @ts-ignore (need dts) 12 | window.uikit = electronAPI 13 | } 14 | -------------------------------------------------------------------------------- /packages/core/src/main.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { session as _session } from 'electron' 3 | 4 | export type { 5 | IpcRenderer, 6 | NodeProcess, 7 | WebFrame, 8 | ElectronAPI as UIKitAPI 9 | } from '@electron-toolkit/preload' 10 | 11 | type UIKitOptions = { 12 | /** 13 | * Attach ES module preload script, Default: false 14 | */ 15 | esModule: boolean 16 | } 17 | 18 | /** 19 | * Use UI kit for the specified session. 20 | */ 21 | export function useUIKit( 22 | session = _session.defaultSession, 23 | options: UIKitOptions = { esModule: false } 24 | ): void { 25 | const electronVer = process.versions.electron 26 | const electronMajorVer = electronVer ? parseInt(electronVer.split('.')[0]) : 0 27 | 28 | const preloadPath = fileURLToPath( 29 | new URL( 30 | options.esModule ? 'uikit-preload.mjs' : 'uikit-preload.cjs', 31 | import.meta.url 32 | ) 33 | ) 34 | 35 | if (electronMajorVer >= 35) { 36 | session.registerPreloadScript({ type: 'frame', filePath: preloadPath }) 37 | } else { 38 | session.setPreloads([...session.getPreloads(), preloadPath]) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/core/src/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from 'electron' 2 | import { electronAPI } from '@electron-toolkit/preload' 3 | 4 | /** 5 | * Expose UI kit in the specified preload script. 6 | */ 7 | export function exposeUIKit(): void { 8 | if (process.contextIsolated) { 9 | try { 10 | contextBridge.exposeInMainWorld('uikit', electronAPI) 11 | } catch (error) { 12 | console.error(error) 13 | } 14 | } else { 15 | // @ts-ignore (need dts) 16 | window.uikit = electronAPI 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/notification/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v1.0.0 (_2024-06-23_) 2 | 3 | -`@electron-uikit/notification` init 4 | -------------------------------------------------------------------------------- /packages/notification/README.md: -------------------------------------------------------------------------------- 1 | # @electron-uikit/notification 2 | 3 | ![notification version](https://img.shields.io/npm/v/@electron-uikit/notification.svg?color=orange&label=version) 4 | 5 | Notification for Electron app. 6 | 7 | Windows notifications are not very nice and friendly, which is the motivation for designing this library. In Windows, notifications based on the implementation of the Electron window are used. In other platforms such as macOS, Electron notifications will still be used, so it can be used on any platform. 8 | 9 | | Default | Customization | 10 | | :-----------------------------------: | :-----------------------------------------------: | 11 | | ![default](./screenshots/default.png) | ![customization](./screenshots/customization.png) | 12 | 13 | ## Features 14 | 15 | - MacOS notifications styles 16 | - Keep only one window and automatically update notifications 17 | - Supports hover stay 18 | - Supports dark mode 19 | - Fully customizable 20 | - Compatible with Windows, MacOS and Linux. 21 | 22 | ## Usage 23 | 24 | ### Install 25 | 26 | ```sh 27 | npm i @electron-uikit/core @electron-uikit/notification 28 | ``` 29 | 30 | ### Get Started 31 | 32 | #### Using the notification in the main process. 33 | 34 | ```js 35 | import { join } from 'path' 36 | import { notification } from '@electron-uikit/notification' 37 | 38 | notification.config({ 39 | /** 40 | * Time in milliseconds before notification is closed and 41 | * should be greater then 2000. Defaults to 5000. 42 | */ 43 | duration: 10000, 44 | /** 45 | * Default icon for the notification. 46 | */ 47 | icon: join(__dirname, '../.../resources/icon.png') 48 | //... 49 | }) 50 | 51 | // Only supports listening in the main process 52 | notification.on('click', (data) => { 53 | if (data.type === 1) { 54 | //... 55 | } 56 | if (data.type === 2) { 57 | //... 58 | } 59 | // ... 60 | }) 61 | 62 | notification.show({ 63 | title: 'Electron UIKit', 64 | body: 'Gorgeous', 65 | extraData: { type: 1 } // click event data 66 | }) 67 | ``` 68 | 69 | #### Using the notification in the renderer process. 70 | 71 | 1. Exposes the UI Kit APIs for components. See [@electron-uikit/core](https://github.com/alex8088/electron-uikit/tree/main/packages/core) guide for more details. 72 | 73 | You can expose it in the specified preload script: 74 | 75 | ```js 76 | import { exposeUIKit } from '@electron-uikit/core/preload' 77 | 78 | exposeUIKit() 79 | ``` 80 | 81 | Or, you can expose it globally in the main process for all renderer processes: 82 | 83 | ```js 84 | import { useUIKit } from '@electron-uikit/core/main' 85 | 86 | useUIKit() 87 | ``` 88 | 89 | > [!NOTE] 90 | > If you are using [@electron-toolkit/preload](https://github.com/alex8088/electron-toolkit/tree/master/packages/preload) to expose Electron APIs, there is no need to use this module, because `core` is also an export of it. 91 | 92 | 2. Register a listener in the main process, so that you can use it in the renderer process. 93 | 94 | ```js 95 | import { app } from 'electron' 96 | import { registerNotificationListener } from '@electron-uikit/notification' 97 | 98 | app.whenReady().then(() => { 99 | // Register notification IPC listeners 100 | registerNotificationListener() 101 | 102 | // ... 103 | }) 104 | ``` 105 | 106 | 3. Use the notification in the renderer process. 107 | 108 | ```js 109 | import { notification } from '@electron-uikit/notification/renderer' 110 | 111 | notification.show({ 112 | title: 'Electron UIKit', 113 | body: '🎉🌈' 114 | }) 115 | ``` 116 | 117 | ## APIs 118 | 119 | ### `.config(options)` 120 | 121 | Configure notification defaults or customize notification windows. Can only be used in the main process. 122 | 123 | - options: `object` 124 | 125 | - **title**: `string` (optional) - Default title for the notification. It can also be overridden by the title option of the show method. Defaults to [app.name](https://www.electronjs.org/zh/docs/latest/api/app#appname). 126 | - **icon**: `string` | `Electron.NativeImage` (optional) - Default icon for the notification, which can be an icon path, url protocol or [Electron NativeImage](https://www.electronjs.org/zh/docs/latest/api/native-image) object. It can also be overridden by the icon option of the show method. 127 | - **offset**: `number` (optional) _Windows_ - Notification window is offset from the bottom and right side. 128 | - **duration**: `number` (optional) _Windows_ - Time in milliseconds before notification is closed and should be greater then 2000. Defaults to 5000. 129 | - **customPage**:`string` (optional) _Windows_ - Custom html page loaded by notification window. It should be an absolute path. 130 | - **width**: `number` (optional) _Windows_ - Custom notification window width. Only valid if `customPage` has a value. 131 | - **height**: `number` (optional) _Windows_ - Custom notification window height. Only valid if `customPage` has a value. 132 | - **debug**: `boolean` (optional) _Windows_ - When set to `true`, it will open the devTool for debugging custom notification window. You should not enable this in production. 133 | 134 | ### `.show(info)` 135 | 136 | Immediately shows the notification to the user. On Windows, if the notification has been shown before, this method will re-render the notification window and continue to display it. For other platforms, see [Electron's Notification Guide](https://www.electronjs.org/docs/latest/api/notification) for more details. 137 | 138 | - info: `object` 139 | 140 | - **title**: `string` (optional) - A title for the notification, which will be shown at the top of the notification window when it is shown. 141 | - **body**: `string` (optional) - The body text of the notification, which will be displayed below the title. 142 | - **icon**: `string` | `Electron.NativeImage` (optional) - An icon to use in the notification. NativeImage icon is not supported in the renderer process. 143 | - **extraData**: `any` (optional) - Extra data for click events. 144 | 145 | ### `.destroy()` 146 | 147 | Dismiss the notification window. Available for Windows only. 148 | 149 | ### `.on(event, cb)` 150 | 151 | Listen to a notification event. Can only be used in the main process. 152 | 153 | The following notification events are dispatched: 154 | 155 | - `'click'` emitted when the notification is clicked by the user. If the notification is showed without the `extraData` option, this event will not be emitted. The event callback will receive the `extraData` option when emitted. You can customize `extraData` options to define click event handling. 156 | 157 | ```ts 158 | // Only supports listening in the main process 159 | notification.on('click', (data) => { 160 | if (data.type === 1) { 161 | //... 162 | } 163 | if (data.type === 2) { 164 | //... 165 | } 166 | // ... 167 | }) 168 | ``` 169 | 170 | ## Customization 171 | 172 | Customization is only available on Windows. You can use HTML to customize your own notification window UI. 173 | 174 | 1. Customize window UI. 175 | 176 | ```html 177 | 178 | 179 | 180 | 181 | 185 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 211 | 212 | ``` 213 | 214 | 2. Use and debug custom window HTML page. 215 | 216 | ```js 217 | import { join } from 'path' 218 | 219 | notification.config({ 220 | duration: 1000000, // Set a long duration for debugging 221 | customPage: join(__dirname, 'index.html'), 222 | height: 300, 223 | width: 300, 224 | debug: true // Open the devTool for debugging 225 | }) 226 | ``` 227 | -------------------------------------------------------------------------------- /packages/notification/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electron-uikit/notification", 3 | "version": "1.0.0", 4 | "description": "Notification for Electron app.", 5 | "main": "dist/main.cjs", 6 | "module": "dist/main.mjs", 7 | "types": "dist/main.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/main.d.ts", 11 | "import": "./dist/main.mjs", 12 | "require": "./dist/main.cjs" 13 | }, 14 | "./main": { 15 | "types": "./dist/main.d.ts", 16 | "import": "./dist/main.mjs", 17 | "require": "./dist/main.cjs" 18 | }, 19 | "./renderer": { 20 | "types": "./dist/renderer.d.ts", 21 | "import": "./dist/renderer.mjs" 22 | } 23 | }, 24 | "typesVersions": { 25 | "*": { 26 | "main": [ 27 | "./dist/main.d.ts" 28 | ], 29 | "renderer": [ 30 | "./dist/renderer.d.ts" 31 | ] 32 | } 33 | }, 34 | "files": [ 35 | "dist" 36 | ], 37 | "author": "Alex Wei", 38 | "license": "MIT", 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/alex8088/electron-uikit.git", 42 | "directory": "packages/notification" 43 | }, 44 | "bugs": { 45 | "url": "https://github.com/alex8088/electron-uikit/issues" 46 | }, 47 | "homepage": "https://github.com/alex8088/electron-uikit/tree/master/packages/notification#readme", 48 | "keywords": [ 49 | "electron", 50 | "notification" 51 | ], 52 | "scripts": { 53 | "build": "rollup -c rollup.config.ts --configPlugin typescript" 54 | }, 55 | "peerDependencies": { 56 | "@electron-uikit/core": "*", 57 | "electron": ">=15.0.0" 58 | }, 59 | "peerDependenciesMeta": { 60 | "@electron-uikit/core": { 61 | "optional": true 62 | } 63 | }, 64 | "dependencies": { 65 | "@tiny-libs/typed-event-emitter": "^1.0.0" 66 | }, 67 | "devDependencies": { 68 | "@electron-uikit/core": "workspace:^" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/notification/rollup.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | import fs from 'node:fs/promises' 3 | import path from 'node:path' 4 | import { defineConfig } from 'rollup' 5 | import resolve from '@rollup/plugin-node-resolve' 6 | import commonjs from '@rollup/plugin-commonjs' 7 | import ts from '@rollup/plugin-typescript' 8 | import dts from 'rollup-plugin-dts' 9 | import rm from 'rollup-plugin-rm' 10 | 11 | export default defineConfig([ 12 | { 13 | input: ['src/main.ts'], 14 | output: [ 15 | { 16 | entryFileNames: '[name].cjs', 17 | chunkFileNames: 'chunks/lib-[hash].cjs', 18 | format: 'cjs', 19 | dir: 'dist' 20 | }, 21 | { 22 | entryFileNames: '[name].mjs', 23 | chunkFileNames: 'chunks/lib-[hash].mjs', 24 | format: 'es', 25 | dir: 'dist' 26 | } 27 | ], 28 | external: ['electron', '@tiny-libs/typed-event-emitter'], 29 | plugins: [ 30 | resolve(), 31 | commonjs(), 32 | ts({ 33 | compilerOptions: { 34 | rootDir: 'src', 35 | declaration: true, 36 | outDir: 'dist/types' 37 | } 38 | }), 39 | rm('dist', 'buildStart'), 40 | { 41 | name: 'import-meta-url', 42 | resolveImportMeta(property, { format }) { 43 | if (property === 'url' && format === 'cjs') { 44 | return `require("url").pathToFileURL(__filename).href` 45 | } 46 | return null 47 | } 48 | } 49 | ] 50 | }, 51 | { 52 | input: ['src/preload.ts'], 53 | output: [ 54 | { 55 | entryFileNames: '[name].js', 56 | chunkFileNames: 'chunks/lib-[hash].js', 57 | format: 'cjs', 58 | dir: 'dist' 59 | } 60 | ], 61 | external: ['electron'], 62 | plugins: [ 63 | resolve(), 64 | commonjs(), 65 | ts(), 66 | { 67 | name: 'copy-html', 68 | buildEnd: () => { 69 | fs.copyFile( 70 | path.resolve('src/index.html'), 71 | path.resolve('dist/index.html') 72 | ) 73 | fs.copyFile( 74 | path.resolve('src/icon.svg'), 75 | path.resolve('dist/icon.svg') 76 | ) 77 | } 78 | } 79 | ] 80 | }, 81 | { 82 | input: ['src/renderer.ts'], 83 | output: [ 84 | { file: './dist/renderer.mjs', format: 'es' }, 85 | { name: 'renderer', file: './dist/renderer.js', format: 'iife' } 86 | ], 87 | external: ['electron'], 88 | plugins: [ 89 | ts({ 90 | compilerOptions: { 91 | rootDir: 'src', 92 | declaration: true, 93 | outDir: 'dist/types' 94 | } 95 | }) 96 | ] 97 | }, 98 | { 99 | input: ['dist/types/main.d.ts'], 100 | output: [{ file: './dist/main.d.ts', format: 'es' }], 101 | plugins: [dts()] 102 | }, 103 | { 104 | input: ['dist/types/renderer.d.ts'], 105 | output: [{ file: './dist/renderer.d.ts', format: 'es' }], 106 | plugins: [dts(), rm('dist/types', 'buildEnd')] 107 | } 108 | ]) 109 | -------------------------------------------------------------------------------- /packages/notification/screenshots/customization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex8088/electron-uikit/37a35a1ead8d8018e1581ebbf7c7336f199ec1b2/packages/notification/screenshots/customization.png -------------------------------------------------------------------------------- /packages/notification/screenshots/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex8088/electron-uikit/37a35a1ead8d8018e1581ebbf7c7336f199ec1b2/packages/notification/screenshots/default.png -------------------------------------------------------------------------------- /packages/notification/src/common.ts: -------------------------------------------------------------------------------- 1 | import type { UIKitAPI } from '@electron-uikit/core' 2 | 3 | export const core = ((globalThis || window).uikit || 4 | (globalThis || window).electron) as UIKitAPI 5 | -------------------------------------------------------------------------------- /packages/notification/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const NOTIFICATION_WIDTH = 300 2 | export const NOTIFICATION_HEIGHT = 64 3 | export const NOTIFICATION_OFFSET = 6 4 | export const NOTIFICATION_DURATION = 5000 5 | export const NOTIFICATION_CHANNEL = 'uikit:notification' 6 | export const NOTIFICATION_SHOW_CHANNEL = 'uikit:notification:show' 7 | export const NOTIFICATION_CLOSE_CHANNEL = 'uikit:notification:close' 8 | export const NOTIFICATION_ON_CLICK_CHANNEL = 'uikit:notification:on-click' 9 | -------------------------------------------------------------------------------- /packages/notification/src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/notification/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Notifications 6 | 10 | 125 | 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /packages/notification/src/main.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { fileURLToPath } from 'node:url' 3 | import { 4 | app, 5 | ipcMain, 6 | BrowserWindow, 7 | screen, 8 | Notification as ElectronNotification, 9 | type IpcMainEvent 10 | } from 'electron' 11 | import { TypedEventEmitter } from '@tiny-libs/typed-event-emitter' 12 | 13 | import { 14 | NOTIFICATION_WIDTH, 15 | NOTIFICATION_HEIGHT, 16 | NOTIFICATION_OFFSET, 17 | NOTIFICATION_DURATION, 18 | NOTIFICATION_CHANNEL, 19 | NOTIFICATION_SHOW_CHANNEL, 20 | NOTIFICATION_CLOSE_CHANNEL, 21 | NOTIFICATION_ON_CLICK_CHANNEL 22 | } from './constants' 23 | 24 | import type { 25 | NotificationOptions, 26 | NotificationInfo, 27 | SerializableNotificationInfo 28 | } from './types' 29 | 30 | export type { NotificationOptions, NotificationInfo } from './types' 31 | 32 | type NotificationEvents = { 33 | click: (extraData: any) => void 34 | } 35 | 36 | class Notification extends TypedEventEmitter { 37 | private title: string 38 | private icon: string 39 | 40 | private offset = NOTIFICATION_OFFSET 41 | private duration = NOTIFICATION_DURATION 42 | 43 | private customPage?: string 44 | private width = NOTIFICATION_WIDTH 45 | private height = NOTIFICATION_HEIGHT 46 | 47 | private debug = false 48 | 49 | private window?: BrowserWindow 50 | 51 | constructor() { 52 | super() 53 | this.title = app.name 54 | this.icon = fileURLToPath(new URL('icon.svg', import.meta.url)) 55 | } 56 | 57 | private createWindow(info: SerializableNotificationInfo): void { 58 | const { x, y, width, height } = screen.getPrimaryDisplay().workArea 59 | 60 | const win = new BrowserWindow({ 61 | title: 'Notifications', 62 | width: this.width, 63 | height: this.height, 64 | x: x + width - this.width - this.offset, 65 | y: y + height - this.height - this.offset, 66 | show: false, 67 | frame: false, 68 | titleBarStyle: 'hidden', 69 | fullscreenable: false, 70 | minimizable: false, 71 | maximizable: false, 72 | movable: false, 73 | resizable: false, 74 | alwaysOnTop: true, 75 | skipTaskbar: true, 76 | webPreferences: { 77 | preload: fileURLToPath(new URL('preload.js', import.meta.url)), 78 | backgroundThrottling: false, 79 | enableWebSQL: false, 80 | spellcheck: false, 81 | devTools: this.debug && !!this.customPage 82 | } 83 | }) 84 | 85 | win.setAlwaysOnTop(true, 'screen-saver') 86 | 87 | win.on('ready-to-show', () => { 88 | win.webContents.send(NOTIFICATION_SHOW_CHANNEL, info) 89 | win.showInactive() 90 | if (this.debug && !!this.customPage) win.webContents.openDevTools() 91 | }) 92 | 93 | const onClick = (event: IpcMainEvent, extraData): void => { 94 | this.emit('click', extraData) 95 | const win = BrowserWindow.fromWebContents(event.sender) 96 | if (win && !win.isDestroyed()) { 97 | win.close() 98 | } 99 | } 100 | 101 | const onClose = (event: IpcMainEvent, immediately: boolean): void => { 102 | const win = BrowserWindow.fromWebContents(event.sender) 103 | if (win) { 104 | if (!immediately && this.isMouseOver(win)) { 105 | event.reply(NOTIFICATION_SHOW_CHANNEL) 106 | } else { 107 | win.close() 108 | } 109 | } 110 | } 111 | 112 | win.on('closed', () => { 113 | ipcMain.removeListener(NOTIFICATION_ON_CLICK_CHANNEL, onClick) 114 | ipcMain.removeListener(NOTIFICATION_CLOSE_CHANNEL, onClose) 115 | 116 | this.window = undefined 117 | }) 118 | 119 | ipcMain.on(NOTIFICATION_ON_CLICK_CHANNEL, onClick) 120 | ipcMain.on(NOTIFICATION_CLOSE_CHANNEL, onClose) 121 | 122 | win.loadFile( 123 | this.customPage || fileURLToPath(new URL('index.html', import.meta.url)) 124 | ) 125 | 126 | this.window = win 127 | } 128 | 129 | private isMouseOver(window: BrowserWindow): boolean { 130 | const screenCoord = screen.getCursorScreenPoint() 131 | const winBounds = window.getBounds() 132 | return ( 133 | screenCoord.x >= winBounds.x && 134 | screenCoord.x <= winBounds.x + winBounds.width && 135 | screenCoord.y >= winBounds.y && 136 | screenCoord.y <= winBounds.y + winBounds.height 137 | ) 138 | } 139 | 140 | /** 141 | * Configure notification defaults or customize notification windows. 142 | * @param options NotificationOptions 143 | */ 144 | config(options: NotificationOptions): void { 145 | const { 146 | title = app.name, 147 | icon, 148 | offset = NOTIFICATION_OFFSET, 149 | duration = NOTIFICATION_DURATION, 150 | customPage, 151 | width = NOTIFICATION_WIDTH, 152 | height = NOTIFICATION_HEIGHT, 153 | debug = false 154 | } = options 155 | 156 | this.title = title 157 | this.icon = icon 158 | ? typeof icon === 'object' 159 | ? icon.toDataURL() 160 | : icon 161 | : this.icon 162 | 163 | this.offset = offset 164 | this.duration = Math.max(duration, 2000) 165 | 166 | if (customPage) { 167 | this.customPage = customPage 168 | this.width = width 169 | this.height = height 170 | this.debug = debug 171 | } 172 | } 173 | 174 | /** 175 | * Dismiss the notification windows. 176 | * 177 | * @platform win32 178 | */ 179 | destroy(): void { 180 | if (this.window) { 181 | this.window.close() 182 | } 183 | } 184 | 185 | /** 186 | * Immediately shows the notification to the user. On Windows, if the notification 187 | * has been shown before, this method will re-render the notification window and 188 | * continue to display it. For other platforms, 189 | * see [Electron's Notification Guide](https://www.electronjs.org/docs/latest/api/notification) for more details. 190 | * 191 | * @param info NotificationInfo 192 | */ 193 | show(info: NotificationInfo): void { 194 | const { title, body, icon, extraData } = info 195 | 196 | const _info: SerializableNotificationInfo = { 197 | title: title || this.title, 198 | body, 199 | icon: icon 200 | ? typeof icon === 'object' 201 | ? icon.toDataURL() 202 | : icon 203 | : this.icon, 204 | extraData, 205 | duration: this.duration, 206 | custom: !!this.customPage 207 | } 208 | 209 | if (process.platform === 'win32') { 210 | if (this.window && !this.window.isDestroyed()) { 211 | this.window.webContents.send(NOTIFICATION_SHOW_CHANNEL, _info) 212 | 213 | if (!this.window.isVisible()) { 214 | this.window.showInactive() 215 | } 216 | } else { 217 | this.createWindow(_info) 218 | } 219 | } else { 220 | if (!ElectronNotification.isSupported()) { 221 | return 222 | } 223 | 224 | const { title = this.title, body, icon, extraData } = info 225 | 226 | const notifier = new ElectronNotification({ title, body, icon }) 227 | notifier.on('click', () => { 228 | this.emit('click', extraData) 229 | }) 230 | notifier.show() 231 | } 232 | } 233 | } 234 | 235 | export const notification = new Notification() 236 | 237 | /** 238 | * Register the notification ipc listener for use by the renderer. 239 | */ 240 | export function registerNotificationListener(): void { 241 | if (ipcMain.eventNames().some((ename) => ename === NOTIFICATION_CHANNEL)) { 242 | return 243 | } 244 | 245 | ipcMain.on(NOTIFICATION_CHANNEL, (_, info?: NotificationInfo) => { 246 | if (info) { 247 | notification.show(info) 248 | } else { 249 | notification.destroy() 250 | } 251 | }) 252 | } 253 | -------------------------------------------------------------------------------- /packages/notification/src/preload.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { ipcRenderer, contextBridge } from 'electron' 4 | import { 5 | NOTIFICATION_SHOW_CHANNEL, 6 | NOTIFICATION_CLOSE_CHANNEL, 7 | NOTIFICATION_ON_CLICK_CHANNEL 8 | } from './constants' 9 | 10 | import type { SerializableNotificationInfo } from './types' 11 | 12 | let timeout: NodeJS.Timeout | null = null 13 | 14 | ipcRenderer.on( 15 | NOTIFICATION_SHOW_CHANNEL, 16 | (_, data?: SerializableNotificationInfo) => { 17 | let ms 18 | if (data) { 19 | ms = data.duration 20 | if (data.custom) { 21 | const { title, body, icon, extraData } = data 22 | window.dispatchEvent( 23 | new CustomEvent('notification-message', { 24 | detail: { title, body, icon, extraData } 25 | }) 26 | ) 27 | } else { 28 | h(data.title, data.body, data.icon, data.extraData) 29 | } 30 | } else { 31 | ms = 2000 32 | } 33 | if (timeout) clearTimeout(timeout) 34 | timeout = setTimeout(() => { 35 | ipcRenderer.send(NOTIFICATION_CLOSE_CHANNEL, false) 36 | }, ms) 37 | } 38 | ) 39 | 40 | contextBridge.exposeInMainWorld('notification', { 41 | close: () => { 42 | ipcRenderer.send(NOTIFICATION_CLOSE_CHANNEL, true) 43 | }, 44 | doClick: (extraData: any) => { 45 | ipcRenderer.send(NOTIFICATION_ON_CLICK_CHANNEL, extraData) 46 | } 47 | }) 48 | 49 | function h( 50 | title: string, 51 | subtitle?: string, 52 | icon?: string, 53 | extraData?: any 54 | ): void { 55 | while (document.body.firstChild) { 56 | document.body.firstChild.remove() 57 | } 58 | 59 | const container = document.createElement('div') 60 | container.classList.add('n') 61 | 62 | if (extraData) { 63 | container.addEventListener('click', () => { 64 | ipcRenderer.send(NOTIFICATION_ON_CLICK_CHANNEL, extraData) 65 | }) 66 | } 67 | 68 | if (icon) { 69 | const header = document.createElement('div') 70 | header.classList.add('hd') 71 | const img = document.createElement('img') 72 | img.src = icon 73 | img.alt = '' 74 | header.appendChild(img) 75 | 76 | container.appendChild(header) 77 | } 78 | 79 | const body = document.createElement('div') 80 | body.classList.add('bd') 81 | 82 | const h4 = document.createElement('h4') 83 | h4.innerText = title 84 | body.appendChild(h4) 85 | 86 | if (subtitle) { 87 | const p = document.createElement('p') 88 | p.innerText = subtitle 89 | body.appendChild(p) 90 | } 91 | 92 | container.appendChild(body) 93 | 94 | document.body.appendChild(container) 95 | 96 | const close = document.createElement('div') 97 | close.classList.add('close') 98 | 99 | close.addEventListener('click', () => { 100 | ipcRenderer.send(NOTIFICATION_CLOSE_CHANNEL, true) 101 | }) 102 | 103 | document.body.appendChild(close) 104 | } 105 | -------------------------------------------------------------------------------- /packages/notification/src/renderer.ts: -------------------------------------------------------------------------------- 1 | import { core } from './common' 2 | import { NOTIFICATION_CHANNEL } from './constants' 3 | 4 | import type { NotificationInfo } from './types' 5 | 6 | interface NotificationIcon { 7 | /** 8 | * An icon to use in the notification. 9 | */ 10 | icon?: string 11 | } 12 | 13 | export const notification = { 14 | show: (info: Omit & NotificationIcon): void => { 15 | core.ipcRenderer.send(NOTIFICATION_CHANNEL, info) 16 | }, 17 | destroy: (): void => { 18 | core.ipcRenderer.send(NOTIFICATION_CHANNEL) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/notification/src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { NativeImage } from 'electron' 4 | 5 | export type NotificationOptions = { 6 | /** 7 | * Default title for the notification. It can also be overridden by the 8 | * title option of the show method. Defaults to 9 | * [`app.name`](https://www.electronjs.org/zh/docs/latest/api/app#appname). 10 | */ 11 | title?: string 12 | /** 13 | * Default icon for the notification, which can be an icon path, url protocol 14 | * or [Electron NativeImage](https://www.electronjs.org/zh/docs/latest/api/native-image) object. 15 | * It can also be overridden by the icon option of the show method. 16 | */ 17 | icon?: string | NativeImage 18 | /** 19 | * Notification window is offset from the bottom and right side. 20 | * 21 | * @platform win32 22 | */ 23 | offset?: number 24 | /** 25 | * Time in milliseconds before notification is closed and should be greater then `2000`. 26 | * Defaults to `5000`. 27 | * 28 | * @platform win32 29 | */ 30 | duration?: number 31 | /** 32 | * Custom html page loaded by notification window. It should be an absolute path. 33 | * 34 | * @platform win32 35 | */ 36 | customPage?: string 37 | /** 38 | * Custom notification window width. Only valid if `customPage` has a value. 39 | * 40 | * @platform win32 41 | */ 42 | width?: number 43 | /** 44 | * Custom notification window height. Only valid if `customPage` has a value. 45 | * 46 | * @platform win32 47 | */ 48 | height?: number 49 | /** 50 | * When set to `true`, it will open the devTool for debugging custom notification 51 | * window. You should not enable this in production. 52 | * 53 | * @platform win32 54 | */ 55 | debug?: boolean 56 | } 57 | 58 | export interface NotificationInfo { 59 | /** 60 | * A title for the notification, which will be shown at the top of the notification 61 | * window when it is shown. 62 | */ 63 | title?: string 64 | /** 65 | * The body text of the notification, which will be displayed below the title. 66 | */ 67 | body?: string 68 | /** 69 | * An icon to use in the notification. 70 | */ 71 | icon?: string | NativeImage 72 | /** 73 | * Extra data for click events 74 | */ 75 | extraData?: any 76 | } 77 | 78 | export interface SerializableNotificationInfo { 79 | title: string 80 | body?: string 81 | icon?: string 82 | extraData?: any 83 | duration: number 84 | custom: boolean 85 | } 86 | -------------------------------------------------------------------------------- /packages/notification/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/playground/.env.preload: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | VITE_USE_PRELOAD=true 3 | -------------------------------------------------------------------------------- /packages/playground/README.md: -------------------------------------------------------------------------------- 1 | # Playground for Electron UIKit 2 | -------------------------------------------------------------------------------- /packages/playground/electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 2 | 3 | export default defineConfig(({ mode }) => { 4 | return { 5 | main: { 6 | plugins: [externalizeDepsPlugin()] 7 | }, 8 | ...(mode === 'preload' ? { preload: {} } : null), 9 | renderer: {} 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /packages/playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electron-uikit/playground", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "out/main/index.js", 6 | "scripts": { 7 | "dev": "electron-vite dev --ignoreConfigWarning", 8 | "dev:preload": "electron-vite dev --mode preload" 9 | }, 10 | "dependencies": { 11 | "@electron-uikit/contextmenu": "workspace:^", 12 | "@electron-uikit/core": "workspace:^", 13 | "@electron-uikit/notification": "workspace:^", 14 | "@electron-uikit/titlebar": "workspace:^", 15 | "@electron-uikit/toast": "workspace:^" 16 | }, 17 | "devDependencies": { 18 | "electron-vite": "^2.2.0", 19 | "vite": "^5.3.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/playground/src/main/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_USE_PRELOAD: string 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv 9 | } 10 | -------------------------------------------------------------------------------- /packages/playground/src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, nativeTheme, shell } from 'electron' 2 | import { join } from 'path' 3 | 4 | import { useUIKit } from '@electron-uikit/core' 5 | import { registerContextMenuListener } from '@electron-uikit/contextmenu' 6 | import { 7 | notification, 8 | registerNotificationListener 9 | } from '@electron-uikit/notification' 10 | import { 11 | registerTitleBarListener, 12 | attachTitleBarToWindow 13 | } from '@electron-uikit/titlebar' 14 | import { Toast } from '@electron-uikit/toast' 15 | 16 | function createWindow(): void { 17 | const mainWindow = new BrowserWindow({ 18 | title: 'Electron UIKit', 19 | width: 720, 20 | height: 520, 21 | show: false, 22 | autoHideMenuBar: true, 23 | titleBarStyle: 'hidden', 24 | webPreferences: { 25 | ...(import.meta.env.VITE_USE_PRELOAD === 'true' 26 | ? { preload: join(__dirname, '../preload/index.js') } 27 | : {}), 28 | sandbox: false 29 | } 30 | }) 31 | 32 | attachTitleBarToWindow(mainWindow) 33 | 34 | mainWindow.on('ready-to-show', () => { 35 | mainWindow.show() 36 | }) 37 | 38 | mainWindow.webContents.on('before-input-event', (_, input) => { 39 | if (input.type === 'keyDown') { 40 | if (input.code === 'F12') { 41 | if (mainWindow.webContents.isDevToolsOpened()) { 42 | mainWindow.webContents.closeDevTools() 43 | } else { 44 | mainWindow.webContents.openDevTools({ mode: 'undocked' }) 45 | } 46 | } 47 | } 48 | }) 49 | 50 | if (!app.isPackaged && process.env['ELECTRON_RENDERER_URL']) { 51 | mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) 52 | } else { 53 | mainWindow.loadFile(join(__dirname, '../renderer/index.html')) 54 | } 55 | } 56 | 57 | app.whenReady().then(() => { 58 | if (import.meta.env.VITE_USE_PRELOAD !== 'true') { 59 | useUIKit() 60 | } 61 | 62 | registerTitleBarListener() 63 | registerContextMenuListener() 64 | registerNotificationListener() 65 | 66 | notification.config({ 67 | title: 'Electron UIKit' 68 | }) 69 | 70 | notification.on('click', (data) => { 71 | if (data.type === 1) { 72 | shell.openExternal( 73 | 'https://github.com/alex8088/electron-uikit/tree/main/packages/notification' 74 | ) 75 | } 76 | }) 77 | 78 | createWindow() 79 | 80 | ipcMain.handle('dark-mode:toggle', () => { 81 | if (nativeTheme.shouldUseDarkColors) { 82 | nativeTheme.themeSource = 'light' 83 | } else { 84 | nativeTheme.themeSource = 'dark' 85 | } 86 | return nativeTheme.shouldUseDarkColors 87 | }) 88 | 89 | ipcMain.on('toast:loading', (e) => { 90 | const win = BrowserWindow.fromWebContents(e.sender) 91 | if (win) { 92 | const toast = new Toast(win) 93 | const reply = toast.loading('Downloading') 94 | setTimeout(() => reply.dismiss(), 3000) 95 | } 96 | }) 97 | 98 | app.on('activate', function () { 99 | if (BrowserWindow.getAllWindows().length === 0) createWindow() 100 | }) 101 | }) 102 | 103 | app.on('window-all-closed', () => { 104 | if (process.platform !== 'darwin') { 105 | app.quit() 106 | } 107 | }) 108 | -------------------------------------------------------------------------------- /packages/playground/src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { exposeUIKit } from '@electron-uikit/core/preload' 2 | 3 | exposeUIKit() 4 | -------------------------------------------------------------------------------- /packages/playground/src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Electron UIKit 6 | 7 | 11 | 14 | 92 | 93 | 94 | 95 | 96 |
97 | 98 |
99 |
display none
100 |
101 |
102 | Electron UIKit+ 103 |
104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /packages/playground/src/renderer/public/moon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /packages/playground/src/renderer/public/sun.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /packages/playground/src/renderer/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/playground/src/renderer/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Menu, MenuItem } from '@electron-uikit/contextmenu/renderer' 2 | import { notification } from '@electron-uikit/notification/renderer' 3 | import { toast } from '@electron-uikit/toast/renderer' 4 | 5 | function init(): void { 6 | toast.config({ 7 | supportMain: true, 8 | bottom: 80 9 | }) 10 | window.addEventListener('DOMContentLoaded', () => { 11 | const theme = document.getElementById('theme') 12 | const icon = document.getElementById('icon') 13 | theme?.addEventListener('click', () => { 14 | window.uikit.ipcRenderer 15 | .invoke('dark-mode:toggle') 16 | .then((dark: boolean) => { 17 | if (dark) { 18 | icon?.classList.replace('icon-sun', 'icon-moon') 19 | } else { 20 | icon?.classList.replace('icon-moon', 'icon-sun') 21 | } 22 | }) 23 | }) 24 | 25 | const body = document.getElementById('app') 26 | body?.addEventListener('contextmenu', (e) => { 27 | e.preventDefault() 28 | 29 | const menu = new Menu() 30 | menu.append( 31 | new MenuItem({ 32 | label: 'Popup Notification', 33 | click: (): void => { 34 | notification.show({ body: 'Gorgeous', extraData: { type: 1 } }) 35 | } 36 | }) 37 | ) 38 | menu.append(new MenuItem({ type: 'separator' })) 39 | menu.append( 40 | new MenuItem({ 41 | label: 'Show Text Toast', 42 | click: (): void => { 43 | toast.text('I am toast', 4000) 44 | } 45 | }) 46 | ) 47 | menu.append( 48 | new MenuItem({ 49 | label: 'Show Loading Toast', 50 | click: (): void => { 51 | const reply = toast.loading('Loading') 52 | setTimeout(() => reply.success('Done'), 3000000) 53 | } 54 | }) 55 | ) 56 | menu.append( 57 | new MenuItem({ 58 | label: 'Show Loading Toast (Main Process)', 59 | click: (): void => { 60 | window.uikit.ipcRenderer.send('toast:loading') 61 | } 62 | }) 63 | ) 64 | menu.popup() 65 | }) 66 | }) 67 | } 68 | 69 | init() 70 | -------------------------------------------------------------------------------- /packages/playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/titlebar/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v1.2.0 (_2024-12-30_) 2 | 3 | - feat: handle updates to the windowtitle attribute 4 | 5 | ### v1.1.0 (_2024-08-11_) 6 | 7 | - fix: style for overlay mode 8 | 9 | ### v1.0.0 (_2024-06-23_) 10 | 11 | - `@electron-uikit/titlebar` init 12 | -------------------------------------------------------------------------------- /packages/titlebar/README.md: -------------------------------------------------------------------------------- 1 | # @electron-uikit/titlebar 2 | 3 | ![titlebar version](https://img.shields.io/npm/v/@electron-uikit/titlebar.svg?color=orange&label=version) 4 | 5 | Title bar web component for Electron app. 6 | 7 | The title bar web component is used to simulate the Windows title bar for better customization. It's very easy to use and get the same experience as a native title bar. Fully compatible with Windows, MacOS and Linux. 8 | 9 |

10 | 11 |

12 | 13 |

14 | 15 |

16 | 17 | ## Features 18 | 19 | - Customization 20 | - Fully compatible with Windows, MacOS and Linux. 21 | - Support dark mode 22 | - Based on Web component 23 | 24 | ## Usage 25 | 26 | ### Install 27 | 28 | ```sh 29 | npm i @electron-uikit/core @electron-uikit/titlebar 30 | ``` 31 | 32 | ### Get Started 33 | 34 | 1. Exposes the UI Kit APIs for components. See [@electron-uikit/core](https://github.com/alex8088/electron-uikit/tree/main/packages/core) guide for more details. 35 | 36 | You can expose it in the specified preload script: 37 | 38 | ```js 39 | import { exposeUIKit } from '@electron-uikit/core/preload' 40 | 41 | exposeUIKit() 42 | ``` 43 | 44 | Or, you can expose it globally in the main process for all renderer processes: 45 | 46 | ```js 47 | import { useUIKit } from '@electron-uikit/core/main' 48 | 49 | useUIKit() 50 | ``` 51 | 52 | > [!NOTE] 53 | > If you are using [@electron-toolkit/preload](https://github.com/alex8088/electron-toolkit/tree/master/packages/preload) to expose Electron APIs, there is no need to use this module, because `core` is also an export of it. 54 | 55 | 2. Register a listener and attach a title bar to the window in the main process, so that you can use it in the renderer process. 56 | 57 | ```js 58 | import { app, BrowserWindow } from 'electron' 59 | import { 60 | registerTitleBarListener, 61 | attachTitleBarToWindow 62 | } from '@electron-uikit/titlebar' 63 | 64 | app.whenReady().then(() => { 65 | // Register title bar IPC listeners 66 | registerTitleBarListener() 67 | 68 | // Create a window without title bar 69 | const win = new BrowserWindow({ titleBarStyle: 'hidden' }) 70 | 71 | // Attach a title bar to the window 72 | attachTitleBarToWindow(win) 73 | }) 74 | ``` 75 | 76 | 3. Use the title bar web component in window html page. 77 | 78 | ```html 79 | 80 | 81 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | ``` 91 | 92 | ## Components 93 | 94 | ### Attributes 95 | 96 | | Attribute | Description | Type | Default | 97 | | ------------------- | -------------------------------------------------------------------- | --------- | ------- | 98 | | `windowtitle` | Window title | `string` | - | 99 | | `overlay` | Set window controls overlay | `boolean` | false | 100 | | `nominimize` | If specified, the title bar will not show the minimization control | `boolean` | false | 101 | | `nomaximize` | If specified, the title bar will not show the nomaximization control | `boolean` | false | 102 | | `noclose` | If specified, the title bar will not show the `x` control | `boolean` | false | 103 | | `supportfullscreen` | Support fullscreen window mode | `boolean` | false | 104 | 105 | ### CSS Properties 106 | 107 | | Property | Description | Default | 108 | | --------------------------- | ----------------------------- | ----------------------------------------------------- | 109 | | `--tb-theme-color` | Theme color | light: `#ffffff`, dark: `#1f1f1f` | 110 | | `--tb-title-text-color` | Window title text color | light: `#333333`, dark: `#cccccc` | 111 | | `--tb-title-font-family` | Window title text font family | -apple-system, BlinkMacSystemFont, Ubuntu, 'Segoe UI' | 112 | | `--tb-control-symbol-color` | Window control symbol color | light: `#585c65`, dark: `#cccccc` | 113 | | `--tb-control-hover-color` | Window control hover color | light: `#e1e1e1`, dark: `#373737` | 114 | | `--tb-control-height` | Window control height | `26px` (minimum height) | 115 | | `--tb-control-width ` | Window control width | `34px` (minimum width) | 116 | | `--tb-control-margin ` | Window control margins | `3px` (MacOS only) | 117 | | `--tb-control-radius ` | Window control radius | `5px` (MacOS only) | 118 | 119 | ## Customization 120 | 121 | ### Theme Color 122 | 123 | The default theme CSS is customizable by overriding the components CSS variables: 124 | 125 | ```css 126 | .titlebar { 127 | height: 32px; 128 | --tb-theme-color: #ffffff; 129 | --tb-title-text-color: #333333; 130 | --tb-control-symbol-color: #585c65; 131 | --tb-control-hover-color: #e1e1e1; 132 | } 133 | ``` 134 | 135 | Support dark mode by overriding the component CSS variable with `prefers-color-scheme`: 136 | 137 | ```css 138 | @media (prefers-color-scheme: dark) { 139 | .titlebar { 140 | --tb-theme-color: #1f1f1f; 141 | --tb-title-text-color: #cccccc; 142 | --tb-control-symbol-color: #cccccc; 143 | --tb-control-hover-color: #373737; 144 | } 145 | } 146 | ``` 147 | 148 | ### Window Controls 149 | 150 | - `Overlay mode` 151 | 152 | The titlebar only has window control display and no drag region for application. 153 | 154 | ```html 155 | 156 | ``` 157 | 158 | - `Control size and display` 159 | 160 | ```html 161 | 162 | ``` 163 | 164 | - `Customization` 165 | 166 | It is very easy to customize a window control, just define the control icon CSS and bind event handlers to the control element. 167 | 168 | ```css 169 | /* CSS style: control icon */ 170 | .icon { 171 | height: 13px; 172 | width: 13px; 173 | background-color: #333; 174 | mask-position: 50% 50%; 175 | mask-repeat: no-repeat; 176 | mask-size: 100%; 177 | } 178 | 179 | .icon-sun { 180 | mask-image: url(/sun.svg); 181 | } 182 | 183 | .icon-moon { 184 | mask-image: url(/moon.svg); 185 | } 186 | ``` 187 | 188 | ```html 189 | 190 | 191 |
192 | 193 |
194 |
195 | ``` 196 | 197 | ```js 198 | const control = document.getElementById('control') 199 | control?.addEventListener('click', () => { 200 | // ... 201 | }) 202 | ``` 203 | 204 | > [!NOTE] 205 | > Only elements with the `window__control` class in the title bar slot will be recognized as window controls. Other elements will not be displayed. 206 | 207 | ## Integrations 208 | 209 | ### VS Code IDE 210 | 211 | Add a file `.vscode/settings.json` with the following configuration: 212 | 213 | ```json 214 | { 215 | "html.customData": [ 216 | "./node_modules/@electron-uikit/titlebar/custom-elements.json" 217 | ] 218 | } 219 | ``` 220 | 221 | VS Code will load additional title bar HTML entities, so they would show up in auto-completion, hover information etc. 222 | 223 | ### TypeScript and TSX 224 | 225 | If you are a TypeScript user, make sure to add a \*.d.ts declaration file to get type checks and intellisense: 226 | 227 | ```ts 228 | /// 229 | ``` 230 | 231 | For `.tsx` support, a React example: 232 | 233 | ```ts 234 | import { HTMLTitleBarElementAttributes } from '@electron-uikit/titlebar/renderer' 235 | 236 | declare global { 237 | namespace JSX { 238 | interface IntrinsicElements { 239 | // Define an interface for the web component props 240 | 'title-bar': Partial 241 | } 242 | } 243 | } 244 | ``` 245 | -------------------------------------------------------------------------------- /packages/titlebar/custom-elements.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1.1, 3 | "tags": [ 4 | { 5 | "name": "title-bar", 6 | "description": "Title bar web component for Electron. Example:\n```html\n\n```", 7 | "references": [ 8 | { 9 | "name": "Electron UIKit Reference", 10 | "url": "https://github.com/alex8088/electron-uikit" 11 | } 12 | ], 13 | "attributes": [ 14 | { 15 | "name": "windowtitle", 16 | "description": "Window title." 17 | }, 18 | { 19 | "name": "overlay", 20 | "description": "Set window controls overlay." 21 | }, 22 | { 23 | "name": "nominimize", 24 | "description": "If specified, the title bar will not show the minimization control." 25 | }, 26 | { 27 | "name": "nomaximize", 28 | "description": "If specified, the title bar will not show the nomaximization control." 29 | }, 30 | { 31 | "name": "noclose", 32 | "description": "If specified, the title bar will not show the `x` control." 33 | }, 34 | { 35 | "name": "supportfullscreen", 36 | "description": "Support fullscreen window mode." 37 | } 38 | ] 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /packages/titlebar/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electron-uikit/titlebar", 3 | "version": "1.2.0", 4 | "description": "Title bar web component for Electron app.", 5 | "main": "dist/main.cjs", 6 | "module": "dist/main.mjs", 7 | "types": "dist/main.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/main.d.ts", 11 | "import": "./dist/main.mjs", 12 | "require": "./dist/main.cjs" 13 | }, 14 | "./main": { 15 | "types": "./dist/main.d.ts", 16 | "import": "./dist/main.mjs", 17 | "require": "./dist/main.cjs" 18 | }, 19 | "./renderer": { 20 | "types": "./dist/renderer.d.ts", 21 | "import": "./dist/renderer.mjs" 22 | } 23 | }, 24 | "typesVersions": { 25 | "*": { 26 | "main": [ 27 | "./dist/main.d.ts" 28 | ], 29 | "renderer": [ 30 | "./dist/renderer.d.ts" 31 | ] 32 | } 33 | }, 34 | "files": [ 35 | "dist", 36 | "custom-elements.json" 37 | ], 38 | "author": "Alex Wei", 39 | "license": "MIT", 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/alex8088/electron-uikit.git", 43 | "directory": "packages/titlebar" 44 | }, 45 | "bugs": { 46 | "url": "https://github.com/alex8088/electron-uikit/issues" 47 | }, 48 | "homepage": "https://github.com/alex8088/electron-uikit/tree/master/packages/titlebar#readme", 49 | "keywords": [ 50 | "electron", 51 | "titlebar" 52 | ], 53 | "scripts": { 54 | "build": "rollup -c rollup.config.ts --configPlugin typescript" 55 | }, 56 | "peerDependencies": { 57 | "@electron-uikit/core": "*", 58 | "electron": ">=15.0.0" 59 | }, 60 | "peerDependenciesMeta": { 61 | "@electron-uikit/core": { 62 | "optional": true 63 | } 64 | }, 65 | "devDependencies": { 66 | "@electron-uikit/core": "workspace:^" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/titlebar/rollup.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | import { defineConfig } from 'rollup' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import commonjs from '@rollup/plugin-commonjs' 5 | import ts from '@rollup/plugin-typescript' 6 | import dts from 'rollup-plugin-dts' 7 | import rm from 'rollup-plugin-rm' 8 | 9 | export default defineConfig([ 10 | { 11 | input: ['src/main.ts'], 12 | output: [ 13 | { 14 | entryFileNames: '[name].cjs', 15 | chunkFileNames: 'chunks/lib-[hash].cjs', 16 | format: 'cjs', 17 | dir: 'dist' 18 | }, 19 | { 20 | entryFileNames: '[name].mjs', 21 | chunkFileNames: 'chunks/lib-[hash].mjs', 22 | format: 'es', 23 | dir: 'dist' 24 | } 25 | ], 26 | external: ['electron'], 27 | plugins: [ 28 | resolve(), 29 | commonjs(), 30 | ts({ 31 | compilerOptions: { 32 | rootDir: 'src', 33 | declaration: true, 34 | outDir: 'dist/types' 35 | } 36 | }), 37 | rm('dist', 'buildStart') 38 | ] 39 | }, 40 | { 41 | input: ['src/renderer.ts'], 42 | output: [ 43 | { file: './dist/renderer.mjs', format: 'es' }, 44 | { name: 'renderer', file: './dist/renderer.js', format: 'iife' } 45 | ], 46 | plugins: [ 47 | ts({ 48 | compilerOptions: { 49 | rootDir: 'src', 50 | declaration: true, 51 | outDir: 'dist/types' 52 | } 53 | }) 54 | ] 55 | }, 56 | { 57 | input: ['dist/types/main.d.ts'], 58 | output: [{ file: './dist/main.d.ts', format: 'es' }], 59 | plugins: [dts()], 60 | external: ['electron'] 61 | }, 62 | { 63 | input: ['dist/types/renderer.d.ts'], 64 | output: [{ file: './dist/renderer.d.ts', format: 'es' }], 65 | plugins: [dts(), rm('dist/types', 'buildEnd')] 66 | } 67 | ]) 68 | -------------------------------------------------------------------------------- /packages/titlebar/screenshots/mac-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex8088/electron-uikit/37a35a1ead8d8018e1581ebbf7c7336f199ec1b2/packages/titlebar/screenshots/mac-light.png -------------------------------------------------------------------------------- /packages/titlebar/screenshots/win32-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex8088/electron-uikit/37a35a1ead8d8018e1581ebbf7c7336f199ec1b2/packages/titlebar/screenshots/win32-dark.png -------------------------------------------------------------------------------- /packages/titlebar/src/common.ts: -------------------------------------------------------------------------------- 1 | import type { UIKitAPI } from '@electron-uikit/core' 2 | 3 | export const core = ((globalThis || window).uikit || 4 | (globalThis || window).electron) as UIKitAPI 5 | -------------------------------------------------------------------------------- /packages/titlebar/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const TITLE_BAR_CHANNEL = 'uikit:titlebar' 2 | export const TITLE_BAR_MAXIMIZE_REPLY_CHANNEL = 'uikit:titlebar:maximize-reply' 3 | export const TITLE_BAR_FULLSCREEN_REPLY_CHANNEL = 4 | 'uikit:titlebar:fullscreen-reply' 5 | -------------------------------------------------------------------------------- /packages/titlebar/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain, BrowserWindow } from 'electron' 2 | import { 3 | TITLE_BAR_CHANNEL, 4 | TITLE_BAR_MAXIMIZE_REPLY_CHANNEL, 5 | TITLE_BAR_FULLSCREEN_REPLY_CHANNEL 6 | } from './constants' 7 | 8 | const isMacintosh = process.platform === 'darwin' 9 | 10 | /** 11 | * Register the title bar ipc listener for use by the renderer. 12 | */ 13 | export function registerTitleBarListener(): void { 14 | if (isMacintosh) { 15 | return 16 | } 17 | 18 | if (ipcMain.eventNames().some((ename) => ename === TITLE_BAR_CHANNEL)) { 19 | return 20 | } 21 | 22 | ipcMain.on( 23 | TITLE_BAR_CHANNEL, 24 | ( 25 | e, 26 | action: 27 | | 'show' 28 | | 'showInactive' 29 | | 'minimize' 30 | | 'maximizeOrUnmaximize' 31 | | 'close' 32 | ) => { 33 | const win = BrowserWindow.fromWebContents(e.sender) 34 | 35 | if (win) { 36 | if (action === 'show') { 37 | win.show() 38 | } else if (action === 'showInactive') { 39 | win.showInactive() 40 | } else if (action === 'minimize') { 41 | win.minimize() 42 | } else if (action === 'maximizeOrUnmaximize') { 43 | const isMaximized = win.isMaximized() 44 | if (isMaximized) { 45 | win.unmaximize() 46 | } else { 47 | win.maximize() 48 | } 49 | } else if (action === 'close') { 50 | win.close() 51 | } 52 | } 53 | } 54 | ) 55 | } 56 | 57 | /** 58 | * Attach a title bar to the window. 59 | * @param win BrowserWindow 60 | */ 61 | export function attachTitleBarToWindow(win: BrowserWindow): void { 62 | if (win.fullScreenable) { 63 | win.on('enter-full-screen', () => { 64 | win.webContents.send(TITLE_BAR_FULLSCREEN_REPLY_CHANNEL, 1) 65 | }) 66 | 67 | win.on('leave-full-screen', () => { 68 | win.webContents.send(TITLE_BAR_FULLSCREEN_REPLY_CHANNEL, 0) 69 | }) 70 | } 71 | 72 | if (isMacintosh) { 73 | return 74 | } 75 | 76 | win.on('maximize', () => { 77 | win.webContents.send(TITLE_BAR_MAXIMIZE_REPLY_CHANNEL, 1) 78 | }) 79 | 80 | win.on('unmaximize', () => { 81 | win.webContents.send(TITLE_BAR_MAXIMIZE_REPLY_CHANNEL, 0) 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /packages/titlebar/src/renderer.ts: -------------------------------------------------------------------------------- 1 | import { core } from './common' 2 | import { 3 | TITLE_BAR_CHANNEL, 4 | TITLE_BAR_MAXIMIZE_REPLY_CHANNEL, 5 | TITLE_BAR_FULLSCREEN_REPLY_CHANNEL 6 | } from './constants' 7 | 8 | const SHADOW_ROOT_CSS = ` 9 | :host { 10 | position: relative; 11 | background-color: var(--tb-theme-color, #ffffff); 12 | -webkit-user-select: none; 13 | --tb-title-text-color: #333333; 14 | --tb-title-font-family: -apple-system, BlinkMacSystemFont, Ubuntu, 'Segoe UI'; 15 | --tb-control-height: 26px; 16 | --tb-control-width: 34px; 17 | --tb-control-margin: 3px; 18 | --tb-control-radius: 5px; 19 | --tb-control-symbol-color: #585c65; 20 | --tb-control-hover-color: #e1e1e1; 21 | --tb-control-close-symbol-color: #090909; 22 | } 23 | ::slotted(:not(.window__control)) { 24 | display: none; 25 | } 26 | @media (prefers-color-scheme: dark) { 27 | :host { 28 | background-color: var(--tb-theme-color, #1f1f1f); 29 | --tb-title-text-color: #cccccc; 30 | --tb-control-symbol-color: #cccccc; 31 | --tb-control-hover-color: #373737; 32 | --tb-control-close-symbol-color: #fcfcfc; 33 | } 34 | } 35 | .titlebar__drag-region { 36 | position: absolute; 37 | top: 0; 38 | left: 0; 39 | width: 100%; 40 | height: 100%; 41 | -webkit-app-region: drag; 42 | } 43 | .titlebar__title { 44 | display: flex; 45 | align-items: center; 46 | height: 100%; 47 | padding: 0 12px; 48 | font-family: var(--tb-title-font-family); 49 | font-size: 14px; 50 | font-weight: normal; 51 | color: var(--tb-title-text-color); 52 | } 53 | .titlebar__title.mac { 54 | justify-content: center; 55 | font-weight: 600; 56 | } 57 | .titlebar__window-controls { 58 | position: absolute; 59 | right: 0; 60 | top: 0; 61 | z-index: 10000; 62 | display: flex; 63 | -webkit-app-region: no-drag; 64 | } 65 | .titlebar__window-controls.mac { 66 | margin-top: var(--tb-control-margin); 67 | margin-right: var(--tb-control-margin); 68 | } 69 | .titlebar__window-controls.mac ::slotted(.window__control) { 70 | border-radius: var(--tb-control-radius); 71 | } 72 | .window__control, ::slotted(.window__control) { 73 | display: flex; 74 | align-items: center; 75 | justify-content: center; 76 | width: var(--tb-control-width); 77 | min-width: 34px; 78 | height: var(--tb-control-height); 79 | min-height: 26px; 80 | } 81 | .window__control .window__control-icon { 82 | position: absolute; 83 | height: 10px; 84 | width: 10px; 85 | box-sizing: border-box; 86 | } 87 | .window__control:hover, ::slotted(.window__control:hover) { 88 | background-color: var(--tb-control-hover-color); 89 | } 90 | .window__control-min .window__control-icon:before { 91 | content: ""; 92 | position: relative; 93 | top: 4px; 94 | display: flex; 95 | border-top: 1px solid var(--tb-control-symbol-color); 96 | } 97 | .window__control-max .window__control-icon { 98 | border: 1px solid var(--tb-control-symbol-color); 99 | } 100 | .window__control-max.window__control-max_active .window__control-icon { 101 | position: relative; 102 | border: none; 103 | } 104 | .window__control-max.window__control-max_active .window__control-icon:before, 105 | .window__control-max.window__control-max_active .window__control-icon:after { 106 | content: ''; 107 | display: block; 108 | position: absolute; 109 | border: 1px solid var(--tb-control-symbol-color); 110 | } 111 | .window__control-max.window__control-max_active .window__control-icon:before { 112 | left: 0; 113 | bottom: 0; 114 | width: 6px; 115 | height: 6px; 116 | } 117 | .window__control-max.window__control-max_active .window__control-icon:after { 118 | top: 0; 119 | right: 0; 120 | width: 7px; 121 | height: 7px; 122 | border-left: 0; 123 | border-bottom: 0; 124 | background: linear-gradient(to left, var(--tb-control-symbol-color) 1px, transparent 0) no-repeat bottom right / 1px 1px, linear-gradient(to left, var(--tb-control-symbol-color) 1px, transparent 0) no-repeat top left / 1px 1px; 125 | } 126 | .window__control-close:hover { 127 | background-color: #F45454; 128 | } 129 | .window__control-close:hover .window__control-icon:before, 130 | .window__control-close:hover .window__control-icon:after { 131 | background-color: #fff; 132 | } 133 | .window__control-close .window__control-icon:before, 134 | .window__control-close .window__control-icon:after { 135 | content: ''; 136 | position: absolute; 137 | top: 50%; 138 | left: -1px; 139 | width: 12px; 140 | height: 1px; 141 | background-color: var(--tb-control-close-symbol-color); 142 | } 143 | .window__control-close .window__control-icon:before { 144 | transform: rotate(45deg) translateZ(0); 145 | } 146 | .window__control-close .window__control-icon:after { 147 | transform: rotate(-45deg) translateZ(0); 148 | } 149 | ` 150 | 151 | const OVERLAY_HOST_CSS = ` 152 | :host { 153 | position: absolute; 154 | right: 0; 155 | top: 0; 156 | z-index: 10000; 157 | } 158 | ` 159 | 160 | export default class TitleBar extends HTMLElement { 161 | constructor() { 162 | super() 163 | } 164 | 165 | static get observedAttributes(): string[] { 166 | return ['windowtitle'] 167 | } 168 | 169 | attributeChangedCallback( 170 | name: string, 171 | oldValue: string, 172 | newValue: string 173 | ): void { 174 | if (name === 'windowtitle' && oldValue !== newValue) { 175 | this.updateTitle(newValue) 176 | } 177 | } 178 | 179 | connectedCallback(): void { 180 | const shadow = this.attachShadow({ mode: 'open' }) 181 | 182 | const overlay = this.hasAttribute('overlay') 183 | 184 | const style = document.createElement('style') 185 | style.textContent = SHADOW_ROOT_CSS + (overlay ? OVERLAY_HOST_CSS : '') 186 | shadow.appendChild(style) 187 | 188 | const isMacintosh = core.process.platform === 'darwin' 189 | 190 | if (!overlay) { 191 | const el = document.createElement('div') 192 | el.classList.add('titlebar__drag-region') 193 | shadow.appendChild(el) 194 | 195 | const title = this.getAttribute('windowtitle') 196 | if (title) { 197 | this.updateTitle(title) 198 | } 199 | } 200 | 201 | if (!isMacintosh || this.hasChildNodes()) { 202 | const controls = document.createElement('div') 203 | controls.classList.add('titlebar__window-controls') 204 | 205 | if (isMacintosh) { 206 | controls.classList.add('mac') 207 | } 208 | 209 | controls.appendChild(document.createElement('slot')) 210 | 211 | if (!isMacintosh) { 212 | const minimizable = !this.hasAttribute('nominimize') 213 | 214 | if (minimizable) { 215 | const el = document.createElement('div') 216 | el.classList.add('window__control', 'window__control-min') 217 | 218 | const icon = document.createElement('span') 219 | icon.classList.add('window__control-icon') 220 | 221 | el.appendChild(icon) 222 | 223 | el.addEventListener('click', () => { 224 | core.ipcRenderer.send(TITLE_BAR_CHANNEL, 'minimize') 225 | }) 226 | 227 | controls.appendChild(el) 228 | } 229 | 230 | const maximizable = !this.hasAttribute('nomaximize') 231 | 232 | if (maximizable) { 233 | const el = document.createElement('div') 234 | el.classList.add('window__control', 'window__control-max') 235 | 236 | const icon = document.createElement('span') 237 | icon.classList.add('window__control-icon') 238 | 239 | el.appendChild(icon) 240 | 241 | el.addEventListener('click', () => { 242 | core.ipcRenderer.send(TITLE_BAR_CHANNEL, 'maximizeOrUnmaximize') 243 | }) 244 | 245 | core.ipcRenderer.on( 246 | TITLE_BAR_MAXIMIZE_REPLY_CHANNEL, 247 | (_, maximized: 0 | 1) => { 248 | if (maximized === 1) { 249 | el.classList.add('window__control-max_active') 250 | } else { 251 | el.classList.remove('window__control-max_active') 252 | } 253 | } 254 | ) 255 | 256 | controls.appendChild(el) 257 | } 258 | 259 | const closable = !this.hasAttribute('noclose') 260 | 261 | if (closable) { 262 | const el = document.createElement('div') 263 | el.classList.add('window__control', 'window__control-close') 264 | 265 | const icon = document.createElement('span') 266 | icon.classList.add('window__control-icon') 267 | 268 | el.appendChild(icon) 269 | 270 | el.addEventListener('click', () => { 271 | core.ipcRenderer.send(TITLE_BAR_CHANNEL, 'close') 272 | }) 273 | 274 | controls.appendChild(el) 275 | } 276 | } 277 | 278 | shadow.appendChild(controls) 279 | } 280 | 281 | const fullscreenable = this.hasAttribute('supportfullscreen') 282 | 283 | if (fullscreenable) { 284 | core.ipcRenderer.on( 285 | TITLE_BAR_FULLSCREEN_REPLY_CHANNEL, 286 | (_, fullscreen: 0 | 1) => { 287 | const root = this.shadowRoot 288 | 289 | if (root) { 290 | const region = root.querySelector( 291 | '.titlebar__drag-region' 292 | ) 293 | if (region) { 294 | region.style['app-region'] = fullscreen === 1 ? 'no-drag' : '' 295 | } 296 | 297 | const controls = root.querySelector( 298 | '.titlebar__window-controls' 299 | ) 300 | 301 | if (controls) { 302 | controls.style.display = fullscreen === 1 ? 'none' : '' 303 | } 304 | } 305 | } 306 | ) 307 | } 308 | } 309 | 310 | updateTitle(title: string): void { 311 | const shadow = this.shadowRoot 312 | if (shadow) { 313 | let el = shadow.querySelector('.titlebar__title') 314 | if (el) { 315 | el.innerText = title 316 | } else { 317 | el = document.createElement('div') 318 | el.classList.add('titlebar__title') 319 | const isMacintosh = core.process.platform === 'darwin' 320 | if (isMacintosh) { 321 | el.classList.add('mac') 322 | } 323 | el.innerText = title 324 | shadow.appendChild(el) 325 | } 326 | } 327 | } 328 | } 329 | 330 | customElements.define('title-bar', TitleBar) 331 | 332 | export interface HTMLTitleBarElementAttributes { 333 | /** 334 | * Window title. 335 | */ 336 | windowtitle?: string 337 | /** 338 | * Set window controls overlay. 339 | */ 340 | overlay?: string 341 | /** 342 | * If specified, the title bar will not show the minimization control. 343 | */ 344 | nominimize?: string 345 | /** 346 | * If specified, the title bar will not show the nomaximization control. 347 | */ 348 | nomaximize?: string 349 | /** 350 | * If specified, the title bar will not show the `x` control. 351 | */ 352 | noclose?: string 353 | /** 354 | * Support fullscreen window mode 355 | */ 356 | supportfullscreen: string 357 | } 358 | 359 | /** 360 | * Title bar for Electron 361 | */ 362 | export interface HTMLTitleBarElement 363 | extends HTMLElement, 364 | HTMLTitleBarElementAttributes {} 365 | 366 | declare global { 367 | interface HTMLElementTagNameMap { 368 | 'title-bar': HTMLTitleBarElement 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /packages/titlebar/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/toast/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v1.0.0 (_2024-06-30_) 2 | 3 | -`@electron-uikit/toast` init 4 | -------------------------------------------------------------------------------- /packages/toast/README.md: -------------------------------------------------------------------------------- 1 | # @electron-uikit/toast 2 | 3 | ![toast version](https://img.shields.io/npm/v/@electron-uikit/toast.svg?color=orange&label=version) 4 | 5 | Toast for Electron app. 6 | 7 | Toast is a concise, non-modal notification method that is used to briefly display information on the user interface without interrupting the user's current operation. It is widely used in mobile operating systems such as Android and iOS to provide quick feedback and important prompt information. Through toast notifications, developers can improve user experience and effectively communicate application status changes. 8 | 9 |

10 | 11 |

12 | 13 | ## Usage 14 | 15 | ### Install 16 | 17 | ```sh 18 | npm i @electron-uikit/core @electron-uikit/toast 19 | ``` 20 | 21 | ### Get Started 22 | 23 | #### Using the toast in the renderer process. 24 | 25 | ```js 26 | import { toast } from '@electron-uikit/toast/renderer' 27 | 28 | toast.text('foo') 29 | toast.loading('loading') 30 | ``` 31 | 32 | #### Using the toast in the main process. 33 | 34 | 1. Exposes the UI Kit APIs for components. See [@electron-uikit/core](https://github.com/alex8088/electron-uikit/tree/main/packages/core) guide for more details. 35 | 36 | You can expose it in the specified preload script: 37 | 38 | ```js 39 | import { exposeUIKit } from '@electron-uikit/core/preload' 40 | 41 | exposeUIKit() 42 | ``` 43 | 44 | Or, you can expose it globally in the main process for all renderer processes: 45 | 46 | ```js 47 | import { useUIKit } from '@electron-uikit/core/main' 48 | 49 | useUIKit() 50 | ``` 51 | 52 | > [!NOTE] 53 | > If you are using [@electron-toolkit/preload](https://github.com/alex8088/electron-toolkit/tree/master/packages/preload) to expose Electron APIs, there is no need to use this module, because `core` is also an export of it. 54 | 55 | 2. Register a listener in the renderer process, so that you can use it in the main process. 56 | 57 | ```js 58 | import { toast } from '@electron-uikit/toast/renderer' 59 | 60 | toast.config({ 61 | supportMain: true 62 | }) 63 | ``` 64 | 65 | 3. Use the notification in the main process. 66 | 67 | ```js 68 | import { BrowserWindow } from 'electron' 69 | import { Toast } from '@electron-uikit/toast' 70 | 71 | const win = new BrowserWindow() 72 | 73 | const toast = new Toast(win) 74 | toast.text('foo') 75 | toast.loading('loading') 76 | ``` 77 | 78 | ## APIs 79 | 80 | > [!NOTE] 81 | > To use Toast in the main process, you need to create a Toast instance of the specified window. 82 | 83 | ### `.config(options)` 84 | 85 | Configure toast defaults or customize toast. Can only be used in the renderer process. 86 | 87 | - options: `object` 88 | 89 | - **container**: `HTMLElement` (optional) - Container element of Toast. Default to `document.body`. 90 | - **duration**: `number` (optional) - Display duration in millisecond. If set to `0`, it will not turn off automatically. Default to `2000`. 91 | - **customClass**: `string` (optional) - Custom CSS class name for toast. 92 | - **bottom**: `number` (optional) - Toast position to the bottom. Default to `50`. 93 | - **maxWidth**: `number` (optional) - The maximum width of toast. Default to `320`. 94 | - **color**: `string` (optional) - Toast background color. 95 | - **textColor**: `string` (optional) - Toast text color. 96 | - **fontSize**: `number` (optional) - Toast font size. Default to `14`. 97 | - **iconSize**: `number` (optional) - Toast icon size. Default to `20`. 98 | - **supportMain**: `boolean` (optional) - Support Electron main process. Default to `false`. 99 | 100 | ### `.text(text[, duration])` 101 | 102 | Show text. The default duration is `2000` ms. 103 | 104 | ### `.loading(text[, duration])` 105 | 106 | Show loading. The default duration is 0, which means it is always displayed and can be turned off by calling its return value function. 107 | 108 | ```js 109 | import { toast } from '@electron-uikit/toast/renderer' 110 | 111 | const reply = toast.loading('Loading') 112 | 113 | setTimeout(() => { 114 | reply.success('Successful') 115 | // reply.fail('Failed') 116 | // reply.dismiss() 117 | }, 3000) 118 | ``` 119 | 120 | ## Customization 121 | 122 | 1. Customize using CSS classes 123 | 124 | ```css 125 | .toast { 126 | --toast-bottom: 50px; 127 | --toast-z-index: 5001; 128 | --toast-color: #48484e; 129 | --toast-text-color: #ffffffd1; 130 | --toast-font-size: 14px; 131 | --toast-font-family: -apple-system, BlinkMacSystemFont, Ubuntu, 'Segoe UI'; 132 | --toast-icon-size: 20px; 133 | --toast-max-width: 320px; 134 | } 135 | ``` 136 | 137 | ```js 138 | toast.config({ 139 | customClass: 'toast' 140 | }) 141 | ``` 142 | 143 | 2. Customize using `config` API 144 | 145 | ```js 146 | toast.config({ 147 | bottom: 200, 148 | maxWidth: 280 149 | }) 150 | ``` 151 | -------------------------------------------------------------------------------- /packages/toast/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electron-uikit/toast", 3 | "version": "1.0.0", 4 | "description": "Toast for Electron app.", 5 | "main": "dist/main.cjs", 6 | "module": "dist/main.mjs", 7 | "types": "dist/main.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/main.d.ts", 11 | "import": "./dist/main.mjs", 12 | "require": "./dist/main.cjs" 13 | }, 14 | "./main": { 15 | "types": "./dist/main.d.ts", 16 | "import": "./dist/main.mjs", 17 | "require": "./dist/main.cjs" 18 | }, 19 | "./renderer": { 20 | "types": "./dist/renderer.d.ts", 21 | "import": "./dist/renderer.mjs" 22 | } 23 | }, 24 | "typesVersions": { 25 | "*": { 26 | "main": [ 27 | "./dist/main.d.ts" 28 | ], 29 | "renderer": [ 30 | "./dist/renderer.d.ts" 31 | ] 32 | } 33 | }, 34 | "files": [ 35 | "dist" 36 | ], 37 | "author": "Alex Wei", 38 | "license": "MIT", 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/alex8088/electron-uikit.git", 42 | "directory": "packages/toast" 43 | }, 44 | "bugs": { 45 | "url": "https://github.com/alex8088/electron-uikit/issues" 46 | }, 47 | "homepage": "https://github.com/alex8088/electron-uikit/tree/master/packages/toast#readme", 48 | "keywords": [ 49 | "electron", 50 | "toast" 51 | ], 52 | "scripts": { 53 | "build": "rollup -c rollup.config.ts --configPlugin typescript" 54 | }, 55 | "peerDependencies": { 56 | "@electron-uikit/core": "*", 57 | "electron": ">=15.0.0" 58 | }, 59 | "peerDependenciesMeta": { 60 | "@electron-uikit/core": { 61 | "optional": true 62 | } 63 | }, 64 | "devDependencies": { 65 | "@electron-uikit/core": "workspace:^" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/toast/rollup.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | import { defineConfig } from 'rollup' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import commonjs from '@rollup/plugin-commonjs' 5 | import ts from '@rollup/plugin-typescript' 6 | import dts from 'rollup-plugin-dts' 7 | import rm from 'rollup-plugin-rm' 8 | 9 | export default defineConfig([ 10 | { 11 | input: ['src/main.ts'], 12 | output: [ 13 | { 14 | entryFileNames: '[name].cjs', 15 | chunkFileNames: 'chunks/lib-[hash].cjs', 16 | format: 'cjs', 17 | dir: 'dist' 18 | }, 19 | { 20 | entryFileNames: '[name].mjs', 21 | chunkFileNames: 'chunks/lib-[hash].mjs', 22 | format: 'es', 23 | dir: 'dist' 24 | } 25 | ], 26 | external: ['electron'], 27 | plugins: [ 28 | resolve(), 29 | commonjs(), 30 | ts({ 31 | compilerOptions: { 32 | rootDir: 'src', 33 | declaration: true, 34 | outDir: 'dist/types' 35 | } 36 | }), 37 | rm('dist', 'buildStart') 38 | ] 39 | }, 40 | { 41 | input: ['src/renderer.ts'], 42 | output: [ 43 | { file: './dist/renderer.mjs', format: 'es' }, 44 | { name: 'renderer', file: './dist/renderer.js', format: 'iife' } 45 | ], 46 | plugins: [ 47 | ts({ 48 | compilerOptions: { 49 | rootDir: 'src', 50 | declaration: true, 51 | outDir: 'dist/types' 52 | } 53 | }) 54 | ] 55 | }, 56 | { 57 | input: ['dist/types/main.d.ts'], 58 | output: [{ file: './dist/main.d.ts', format: 'es' }], 59 | plugins: [dts()], 60 | external: ['electron'] 61 | }, 62 | { 63 | input: ['dist/types/renderer.d.ts'], 64 | output: [{ file: './dist/renderer.d.ts', format: 'es' }], 65 | plugins: [dts(), rm('dist/types', 'buildEnd')] 66 | } 67 | ]) 68 | -------------------------------------------------------------------------------- /packages/toast/screenshots/toast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex8088/electron-uikit/37a35a1ead8d8018e1581ebbf7c7336f199ec1b2/packages/toast/screenshots/toast.png -------------------------------------------------------------------------------- /packages/toast/src/common.ts: -------------------------------------------------------------------------------- 1 | import type { UIKitAPI } from '@electron-uikit/core' 2 | 3 | export const core = ((globalThis || window).uikit || 4 | (globalThis || window).electron) as UIKitAPI 5 | -------------------------------------------------------------------------------- /packages/toast/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const TOAST_CHANNEL = 'uikit:toast' 2 | -------------------------------------------------------------------------------- /packages/toast/src/main.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | import { TOAST_CHANNEL } from './constants' 3 | 4 | import type { ToastType, ToastLoadingFn } from './types' 5 | 6 | export type { ToastLoadingFn } from './types' 7 | 8 | /** 9 | * Toast for Electron main process. Before use, you must ensure that 10 | * `toast.config({ supportMain: true }` is configured in the renderer. 11 | * 12 | * @example 13 | * 14 | * ``` 15 | * import { BrowserWindow } from 'electron' 16 | * import { Toast } from '@electron-uikit/toast' 17 | * 18 | * const win = new BrowserWindow() 19 | * 20 | * const toast = new Toast(win) 21 | * toast.text('foo') 22 | * toast.loading('loading') 23 | * ``` 24 | */ 25 | export class Toast { 26 | constructor(readonly win: BrowserWindow) {} 27 | 28 | private show(text: string, type: ToastType, duration?: number): void { 29 | this.win.webContents.send(TOAST_CHANNEL, 1, text, type, duration) 30 | } 31 | 32 | private close(): void { 33 | this.win.webContents.send(TOAST_CHANNEL, 0) 34 | } 35 | 36 | /** 37 | * Show text. The default duration is `2000` ms. 38 | */ 39 | text(text: string, duration?: number): void { 40 | this.show(text, 'text', duration) 41 | } 42 | 43 | /** 44 | * Show loading. The default duration is 0, which means it is always displayed 45 | * and can be turned off by calling its return value function. 46 | * 47 | * @example 48 | * 49 | * ``` 50 | * import { Toast } from '@electron-uikit/toast' 51 | * 52 | * const toast = new Toast(win) 53 | * const reply = toast.loading('Loading') 54 | * 55 | * setTimeout(() => { 56 | * reply.success('Successful') 57 | * }, 3000) 58 | * ``` 59 | */ 60 | loading(text: string, duration = 0): ToastLoadingFn { 61 | this.show(text, 'loading', duration) 62 | return { 63 | success: (text, duration) => this.show(text, 'success', duration), 64 | fail: (text, duration) => this.show(text, 'failed', duration), 65 | dismiss: () => this.close() 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/toast/src/renderer.ts: -------------------------------------------------------------------------------- 1 | import { core } from './common' 2 | import { TOAST_CHANNEL } from './constants' 3 | 4 | import type { ToastType, ToastLoadingFn } from './types' 5 | 6 | export type { ToastLoadingFn } from './types' 7 | 8 | const SHADOW_ROOT_CSS = ` 9 | :host { 10 | position: fixed; 11 | left: 0; 12 | right: 0; 13 | bottom: var(--toast-bottom, 50px); 14 | z-index: var(--toast-z-index, 5001); 15 | display: flex; 16 | flex-direction: column; 17 | align-items: center; 18 | justify-content: flex-end; 19 | height: 0; 20 | overflow: visible; 21 | user-select: none; 22 | --toast-color: #48484e; 23 | --toast-text-color: #ffffffd1; 24 | --toast-font-size: 14px; 25 | --toast-font-family: -apple-system, BlinkMacSystemFont, Ubuntu, 'Segoe UI'; 26 | --toast-icon-size: 20px; 27 | --toast-icon-margin: 0 8px 0 0; 28 | --toast-padding: 6px 14px; 29 | --toast-border-radius: 4px; 30 | --toast-max-width: 320px; 31 | --toast-box-shadow: 0 3px 6px -4px rgba(0, 0, 0, .12), 0 6px 16px 0 rgba(0, 0, 0, .08), 0 9px 28px 8px rgba(0, 0, 0, .05); 32 | } 33 | @keyframes fadeIn { 34 | from { 35 | opacity: 0; 36 | } 37 | to { 38 | opacity: 1; 39 | } 40 | } 41 | @keyframes fadeOut { 42 | from { 43 | opacity: 1; 44 | } 45 | to { 46 | opacity: 0; 47 | } 48 | } 49 | @keyframes loading { 50 | 0% { 51 | transform: rotate3d(0, 0, 1, 0deg); 52 | } 53 | 54 | 100% { 55 | transform: rotate3d(0, 0, 1, 360deg); 56 | } 57 | } 58 | .fade-in { 59 | animation: fadeIn ease 0.3s forwards; 60 | } 61 | .fade-out { 62 | animation: fadeOut ease 0.3s forwards; 63 | } 64 | .icon-loading { 65 | animation: loading 1s infinite cubic-bezier(0, 0, 1, 1); 66 | mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg' stroke='currentColor' stroke-width='4'%3E%3Cpath d='M42 24c0 9.941-8.059 18-18 18S6 33.941 6 24 14.059 6 24 6'%3E%3C/path%3E%3C/svg%3E"); 67 | } 68 | .icon-success { 69 | mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M116 569a28 28 0 0 1-3-37l21-26c9-11 25-14 36-6l180 120c9 6 26 6 35-2l476-386c10-8 27-8 37 2l11 11c11 11 10 27-1 37L397 793a41 41 0 0 1-58-2L116 569z'/%3E%3C/svg%3E"); 70 | } 71 | .icon-failed { 72 | mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M223 854a55 55 0 0 1-39-94l572-572a55 55 0 1 1 78 78L263 838a55 55 0 0 1-40 16z'/%3E%3Cpath d='M795 854a55 55 0 0 1-39-16L184 266a55 55 0 1 1 79-78l571 572a55 55 0 0 1-39 94z'/%3E%3C/svg%3E"); 73 | } 74 | .toast { 75 | box-sizing: border-box; 76 | display: flex; 77 | align-items: center; 78 | padding: var(--toast-padding); 79 | border-radius: var(--toast-border-radius); 80 | flex-wrap: nowrap; 81 | overflow: hidden; 82 | max-width: var(--toast-max-width); 83 | color: var(--toast-text-color); 84 | background-color: var(--toast-color); 85 | box-shadow: var(--toast-box-shadow); 86 | } 87 | .toast__icon { 88 | margin: var(--toast-icon-margin); 89 | height: var(--toast-icon-size); 90 | width: var(--toast-icon-size); 91 | font-size: var(--toast-icon-size); 92 | mask-position: 50% 50%; 93 | mask-repeat: no-repeat; 94 | mask-size: 100%; 95 | background-color: currentColor; 96 | } 97 | .toast__content { 98 | display: inline-block; 99 | line-height: 1.4; 100 | font-size: var(--toast-font-size); 101 | font-family: var(--toast-font-family); 102 | word-break: break-all; 103 | text-align: center; 104 | } 105 | ` 106 | 107 | export type ToastOptions = { 108 | /** 109 | * Container element of Toast. Default to `document.body` 110 | */ 111 | container?: HTMLElement 112 | /** 113 | * Display duration in millisecond. If set to `0`, it will not turn off 114 | * automatically. Default to `2000`. 115 | */ 116 | duration?: number 117 | /** 118 | * Custom CSS class name for toast. 119 | */ 120 | customClass?: string 121 | /** 122 | * Toast position to the bottom. Default to `50`. 123 | */ 124 | bottom?: number 125 | /** 126 | * The maximum width of toast. Default to `320`. 127 | */ 128 | maxWidth?: number 129 | /** 130 | * Toast background color. 131 | */ 132 | color?: string 133 | /** 134 | * Toast text color. 135 | */ 136 | textColor?: string 137 | /** 138 | * Toast font size. Default to `14`. 139 | */ 140 | fontSize?: number 141 | /** 142 | * Toast icon size. Default to `20`. 143 | */ 144 | iconSize?: number 145 | /** 146 | * Support Electron main process. Default to `false`. 147 | */ 148 | supportMain?: boolean 149 | } 150 | 151 | class Toast { 152 | private shadowHost: HTMLElement | null = null 153 | private shadowRoot: ShadowRoot | null = null 154 | private view: HTMLElement | null = null 155 | private timeout: unknown | null = null 156 | 157 | private container?: HTMLElement 158 | private duration = 2000 159 | private customClass?: string 160 | private customStyle: Record = {} 161 | 162 | private h(text: string, type: ToastType = 'text'): HTMLElement { 163 | const view = document.createElement('div') 164 | 165 | const toast = document.createElement('div') 166 | toast.classList.add('toast') 167 | 168 | if (type !== 'text') { 169 | const icon = document.createElement('i') 170 | icon.classList.add('toast__icon') 171 | icon.classList.add(`icon-${type}`) 172 | toast.appendChild(icon) 173 | } 174 | 175 | const content = document.createElement('div') 176 | content.classList.add('toast__content') 177 | content.innerText = text 178 | 179 | toast.appendChild(content) 180 | 181 | view.appendChild(toast) 182 | 183 | return view 184 | } 185 | 186 | private show(text: string, type: ToastType, duration: number): void { 187 | if (this.shadowHost) { 188 | if (this.timeout) { 189 | clearTimeout(this.timeout as number) 190 | } 191 | this.shadowRoot!.removeChild(this.view!) 192 | 193 | this.view = this.h(text, type) 194 | 195 | this.shadowRoot!.appendChild(this.view) 196 | } else { 197 | this.shadowHost = document.createElement('div') 198 | if (this.customClass) { 199 | this.shadowHost.classList.add(this.customClass) 200 | } 201 | 202 | if (this.customStyle) { 203 | for (const [key, value] of Object.entries(this.customStyle)) { 204 | this.shadowHost.style.setProperty(key, value) 205 | } 206 | } 207 | 208 | this.shadowRoot = this.shadowHost.attachShadow({ mode: 'open' }) 209 | 210 | const style = document.createElement('style') 211 | style.textContent = SHADOW_ROOT_CSS 212 | this.shadowRoot.appendChild(style) 213 | 214 | this.view = this.h(text, type) 215 | 216 | this.shadowRoot.appendChild(this.view) 217 | 218 | this.view.classList.add('fade-in') 219 | 220 | if (this.container) { 221 | this.container.appendChild(this.shadowHost) 222 | } else { 223 | document.body.appendChild(this.shadowHost) 224 | } 225 | } 226 | 227 | if (duration > 0) { 228 | this.timeout = setTimeout(() => { 229 | this.view!.classList.add('fade-out') 230 | this.view!.addEventListener('animationend', () => { 231 | this.shadowRoot!.removeChild(this.view!) 232 | this.shadowRoot = null 233 | this.shadowHost!.remove() 234 | this.shadowHost = null 235 | this.view = null 236 | this.timeout = null 237 | }) 238 | }, duration) 239 | } 240 | } 241 | 242 | private close(): void { 243 | if (this.shadowHost) { 244 | if (this.timeout) { 245 | clearTimeout(this.timeout as number) 246 | } 247 | this.view!.classList.add('fade-out') 248 | this.view!.addEventListener('animationend', () => { 249 | this.shadowRoot!.removeChild(this.view!) 250 | this.shadowRoot = null 251 | this.shadowHost!.remove() 252 | this.shadowHost = null 253 | this.view = null 254 | this.timeout = null 255 | }) 256 | } 257 | } 258 | 259 | /** 260 | * Configure toast defaults or customize toast. 261 | */ 262 | config(options: ToastOptions): void { 263 | const { 264 | container, 265 | customClass, 266 | duration, 267 | bottom, 268 | maxWidth, 269 | color, 270 | textColor, 271 | fontSize, 272 | iconSize 273 | } = options 274 | 275 | this.container = container 276 | this.duration = duration || this.duration 277 | this.customClass = customClass 278 | 279 | if (bottom) { 280 | this.customStyle['--toast-bottom'] = `${bottom}px` 281 | } 282 | if (maxWidth) { 283 | this.customStyle['--toast-max-width'] = `${maxWidth}px` 284 | } 285 | if (color) { 286 | this.customStyle['--toast-color'] = color 287 | } 288 | if (textColor) { 289 | this.customStyle['--toast-text-color'] = textColor 290 | } 291 | if (fontSize) { 292 | this.customStyle['--toast-font-size'] = `${fontSize}px` 293 | } 294 | if (iconSize) { 295 | this.customStyle['--toast-icon-size'] = `${iconSize}px` 296 | } 297 | 298 | if (options.supportMain) { 299 | core.ipcRenderer.on( 300 | TOAST_CHANNEL, 301 | ( 302 | _, 303 | action: 0 | 1, 304 | text: string, 305 | type: ToastType, 306 | duration?: number 307 | ) => { 308 | if (action === 1) { 309 | this.show(text, type, duration ?? this.duration) 310 | } else { 311 | this.close() 312 | } 313 | } 314 | ) 315 | } 316 | } 317 | 318 | /** 319 | * Show text. The default duration is `2000` ms. 320 | */ 321 | text(text: string, duration = this.duration): void { 322 | this.show(text, 'text', duration) 323 | } 324 | 325 | /** 326 | * Show loading. The default duration is 0, which means it is always displayed 327 | * and can be turned off by calling its return value function. 328 | * 329 | * @example 330 | * 331 | * ``` 332 | * import { toast } from '@electron-uikit/toast/renderer' 333 | * 334 | * const reply = toast.loading('Loading') 335 | * 336 | * setTimeout(() => { 337 | * reply.success('Successful') 338 | * }, 3000) 339 | * ``` 340 | */ 341 | loading(text: string, duration = 0): ToastLoadingFn { 342 | this.show(text, 'loading', duration) 343 | return { 344 | success: (text, duration = this.duration) => 345 | this.show(text, 'success', duration), 346 | fail: (text, duration = this.duration) => 347 | this.show(text, 'failed', duration), 348 | dismiss: () => this.close() 349 | } 350 | } 351 | } 352 | 353 | export const toast = new Toast() 354 | -------------------------------------------------------------------------------- /packages/toast/src/types.ts: -------------------------------------------------------------------------------- 1 | export type ToastType = 'text' | 'loading' | 'success' | 'failed' 2 | 3 | export type ToastLoadingFn = { 4 | /** 5 | * Toggle loading toast to success status. 6 | */ 7 | success: (text: string, duration?: number) => void 8 | /** 9 | * Toggle loading toast to failed state. 10 | */ 11 | fail: (text: string, duration?: number) => void 12 | /** 13 | * Dismiss loading toast. 14 | */ 15 | dismiss: () => void 16 | } 17 | -------------------------------------------------------------------------------- /packages/toast/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "lib": ["es2022", "esnext", "dom"], 6 | "sourceMap": false, 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "moduleResolution": "Bundler", 10 | "resolveJsonModule": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "skipLibCheck": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitAny": false, 16 | "noImplicitReturns": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["packages"], 4 | "exclude": ["**/dist"] 5 | } 6 | --------------------------------------------------------------------------------