From b3c05c86479ab3188dc7067686d6a4a7199d1f8b Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Mon, 5 Feb 2024 22:26:25 +0900 Subject: [PATCH] refactor(in-app-menu): refactor `in-app-menu` plugin (#1710) Co-authored-by: JellyBrick --- electron.vite.config.mts | 2 + package.json | 6 + pnpm-lock.yaml | 254 +++++++++++++ src/plugins/in-app-menu/assets/close.svg | 3 - src/plugins/in-app-menu/assets/maximize.svg | 3 - src/plugins/in-app-menu/assets/menu.svg | 3 - src/plugins/in-app-menu/assets/minimize.svg | 3 - src/plugins/in-app-menu/assets/unmaximize.svg | 3 - src/plugins/in-app-menu/constants.ts | 11 + src/plugins/in-app-menu/index.ts | 16 +- src/plugins/in-app-menu/main.ts | 2 +- src/plugins/in-app-menu/menu/icons.ts | 14 - src/plugins/in-app-menu/menu/panel.ts | 220 ----------- src/plugins/in-app-menu/renderer.ts | 217 ----------- src/plugins/in-app-menu/renderer.tsx | 57 +++ .../in-app-menu/renderer/IconButton.tsx | 40 ++ .../in-app-menu/renderer/MenuButton.tsx | 42 +++ src/plugins/in-app-menu/renderer/Panel.tsx | 148 ++++++++ .../in-app-menu/renderer/PanelItem.tsx | 331 +++++++++++++++++ src/plugins/in-app-menu/renderer/TitleBar.tsx | 342 ++++++++++++++++++ .../in-app-menu/renderer/WindowController.tsx | 65 ++++ src/plugins/in-app-menu/titlebar.css | 26 +- tsconfig.json | 2 + 23 files changed, 1318 insertions(+), 492 deletions(-) delete mode 100644 src/plugins/in-app-menu/assets/close.svg delete mode 100644 src/plugins/in-app-menu/assets/maximize.svg delete mode 100644 src/plugins/in-app-menu/assets/menu.svg delete mode 100644 src/plugins/in-app-menu/assets/minimize.svg delete mode 100644 src/plugins/in-app-menu/assets/unmaximize.svg create mode 100644 src/plugins/in-app-menu/constants.ts delete mode 100644 src/plugins/in-app-menu/menu/icons.ts delete mode 100644 src/plugins/in-app-menu/menu/panel.ts delete mode 100644 src/plugins/in-app-menu/renderer.ts create mode 100644 src/plugins/in-app-menu/renderer.tsx create mode 100644 src/plugins/in-app-menu/renderer/IconButton.tsx create mode 100644 src/plugins/in-app-menu/renderer/MenuButton.tsx create mode 100644 src/plugins/in-app-menu/renderer/Panel.tsx create mode 100644 src/plugins/in-app-menu/renderer/PanelItem.tsx create mode 100644 src/plugins/in-app-menu/renderer/TitleBar.tsx create mode 100644 src/plugins/in-app-menu/renderer/WindowController.tsx diff --git a/electron.vite.config.mts b/electron.vite.config.mts index b4f6e318c6..af3d94286b 100644 --- a/electron.vite.config.mts +++ b/electron.vite.config.mts @@ -11,6 +11,7 @@ import pluginLoader from './vite-plugins/plugin-loader.mjs'; import type { UserConfig } from 'vite'; import { i18nImporter } from './vite-plugins/i18n-importer.mjs'; +import solidPlugin from 'vite-plugin-solid'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -117,6 +118,7 @@ export default defineConfig({ 'virtual:i18n': i18nImporter(), 'virtual:plugins': pluginVirtualModuleGenerator('renderer'), }), + solidPlugin(), ], root: './src/', build: { diff --git a/package.json b/package.json index 2da88f7e69..52e98b0731 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "@electron/remote": "2.1.2", "@ffmpeg.wasm/core-mt": "0.12.0", "@ffmpeg.wasm/main": "0.12.0", + "@floating-ui/dom": "1.6.1", "@foobar404/wave": "2.0.5", "@jellybrick/electron-better-web-request": "1.0.4", "@jellybrick/mpris-service": "2.1.4", @@ -174,6 +175,10 @@ "semver": "7.5.4", "serve": "14.2.1", "simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9", + "solid-floating-ui": "0.3.1", + "solid-js": "1.8.12", + "solid-styled-components": "0.28.5", + "solid-transition-group": "0.2.3", "ts-morph": "21.0.1", "vudio": "2.1.1", "x11": "2.3.0", @@ -211,6 +216,7 @@ "vite": "5.0.12", "vite-plugin-inspect": "0.8.3", "vite-plugin-resolve": "2.5.1", + "vite-plugin-solid": "2.9.1", "ws": "8.16.0" }, "auto-changelog": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c578eace8..c655bf64f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ dependencies: '@ffmpeg.wasm/main': specifier: 0.12.0 version: 0.12.0 + '@floating-ui/dom': + specifier: 1.6.1 + version: 1.6.1 '@foobar404/wave': specifier: 2.0.5 version: 2.0.5 @@ -135,6 +138,18 @@ dependencies: simple-youtube-age-restriction-bypass: specifier: github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9 version: github.com/organization/Simple-YouTube-Age-Restriction-Bypass/4e2db89ccb2fb880c5110add9ff3f1dfb78d0ff6 + solid-floating-ui: + specifier: 0.3.1 + version: 0.3.1(@floating-ui/dom@1.6.1)(solid-js@1.8.12) + solid-js: + specifier: 1.8.12 + version: 1.8.12 + solid-styled-components: + specifier: 0.28.5 + version: 0.28.5(solid-js@1.8.12) + solid-transition-group: + specifier: 0.2.3 + version: 0.2.3(solid-js@1.8.12) ts-morph: specifier: 21.0.1 version: 21.0.1 @@ -242,6 +257,9 @@ devDependencies: vite-plugin-resolve: specifier: 2.5.1 version: 2.5.1 + vite-plugin-solid: + specifier: 2.9.1 + version: 2.9.1(solid-js@1.8.12)(vite@5.0.12) ws: specifier: 8.16.0 version: 8.16.0(bufferutil@4.0.8)(utf-8-validate@6.0.3) @@ -350,6 +368,13 @@ packages: '@babel/types': 7.23.6 dev: true + /@babel/helper-module-imports@7.18.6: + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.6 + dev: true + /@babel/helper-module-imports@7.22.15: resolution: {integrity: sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==} engines: {node: '>=6.9.0'} @@ -433,6 +458,16 @@ packages: '@babel/types': 7.23.6 dev: true + /@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.23.7): + resolution: {integrity: sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.23.7 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + /@babel/plugin-transform-arrow-functions@7.23.3(@babel/core@7.23.7): resolution: {integrity: sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==} engines: {node: '>=6.9.0'} @@ -1137,6 +1172,23 @@ packages: regenerator-runtime: 0.13.11 dev: false + /@floating-ui/core@1.6.0: + resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} + dependencies: + '@floating-ui/utils': 0.2.1 + dev: false + + /@floating-ui/dom@1.6.1: + resolution: {integrity: sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==} + dependencies: + '@floating-ui/core': 1.6.0 + '@floating-ui/utils': 0.2.1 + dev: false + + /@floating-ui/utils@0.2.1: + resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} + dev: false + /@foobar404/wave@2.0.5: resolution: {integrity: sha512-V/ydadtv5ObCw8aEg+Qy3YSq1eyinEWzJfRI43Ovmj7VmAvEdWAdL7MatoMbiIVYPATkNDVF7GOxX1xirxM9dA==} dev: false @@ -1466,6 +1518,31 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + /@solid-primitives/refs@1.0.6(solid-js@1.8.12): + resolution: {integrity: sha512-ruh4YdVMxThEVnvqbpeLXKojW442vpFU8q7dSKtElGOTa31aKOAkRb9BTbdaTwVjN4BEq79fiiYIXozJNl4dSw==} + peerDependencies: + solid-js: ^1.6.12 + dependencies: + '@solid-primitives/utils': 6.2.2(solid-js@1.8.12) + solid-js: 1.8.12 + dev: false + + /@solid-primitives/transition-group@1.0.4(solid-js@1.8.12): + resolution: {integrity: sha512-9nPg6HYAmEi7riH0C2bSCVw/2asgGSzHuN0yFFYyK9JgmXqJgyeyA+6thZbj7GgUQMRhtBxpH8yG7N2nEh8ttA==} + peerDependencies: + solid-js: ^1.6.12 + dependencies: + solid-js: 1.8.12 + dev: false + + /@solid-primitives/utils@6.2.2(solid-js@1.8.12): + resolution: {integrity: sha512-11ypVbp987XxETeRqY5Y3OmmTpm8/jZqJXRvo6AyqBthzkvvjEdReuUMU2yVb+pwWGxfZpWHZ6EUCcGXUMhfwg==} + peerDependencies: + solid-js: ^1.6.12 + dependencies: + solid-js: 1.8.12 + dev: false + /@szmarczak/http-timer@4.0.6: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -1490,6 +1567,35 @@ packages: path-browserify: 1.0.1 dev: false + /@types/babel__core@7.20.5: + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + dependencies: + '@babel/parser': 7.23.6 + '@babel/types': 7.23.6 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.5 + dev: true + + /@types/babel__generator@7.6.8: + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + dependencies: + '@babel/types': 7.23.6 + dev: true + + /@types/babel__template@7.4.4: + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + dependencies: + '@babel/parser': 7.23.6 + '@babel/types': 7.23.6 + dev: true + + /@types/babel__traverse@7.20.5: + resolution: {integrity: sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==} + dependencies: + '@babel/types': 7.23.6 + dev: true + /@types/cacheable-request@6.0.3: resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} dependencies: @@ -2156,6 +2262,28 @@ packages: - debug dev: false + /babel-plugin-jsx-dom-expressions@0.37.16(@babel/core@7.23.7): + resolution: {integrity: sha512-ItMD16axbk+FqVb9vIbc7AOpNowy46VaSUHaMYPn+erPGpMCxsahQ1Iv+qhPMthjxtn5ROVMZ5AJtQvzjxjiNA==} + peerDependencies: + '@babel/core': ^7.20.12 + dependencies: + '@babel/core': 7.23.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.23.3(@babel/core@7.23.7) + '@babel/types': 7.23.6 + html-entities: 2.3.3 + validate-html-nesting: 1.2.2 + dev: true + + /babel-preset-solid@1.8.12(@babel/core@7.23.7): + resolution: {integrity: sha512-Fx1dYokeRwouWqjLkdobA6qvTAPxFSEU2c5PlkfJjlNyONlSMJQPaX0Bae5pc+5/LNteb9BseOp4UHwQu6VC9Q==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.23.7 + babel-plugin-jsx-dom-expressions: 0.37.16(@babel/core@7.23.7) + dev: true + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2673,6 +2801,9 @@ packages: engines: {node: '>= 6'} dev: false + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + /custom-electron-prompt@1.5.7(electron@29.0.0-beta.5): resolution: {integrity: sha512-ptRPJr6CpT06GWLMtg3GD2Lr7gWfXdWI+hR1S39eq+m/mUa2E118YmX6mPCbHdg5QB/W9UVhSpRqBM8FUh1G8w==} peerDependencies: @@ -4069,6 +4200,14 @@ packages: slash: 4.0.0 dev: true + /goober@2.1.14(csstype@3.1.3): + resolution: {integrity: sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==} + peerDependencies: + csstype: ^3.0.10 + dependencies: + csstype: 3.1.3 + dev: false + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -4160,6 +4299,10 @@ packages: resolution: {integrity: sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==} dev: false + /html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + dev: true + /html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -4530,6 +4673,11 @@ packages: get-intrinsic: 1.2.2 dev: false + /is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + dev: true + /is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -4847,6 +4995,13 @@ packages: yargs-parser: 20.2.9 dev: true + /merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + dependencies: + is-what: 4.1.16 + dev: true + /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: false @@ -5800,6 +5955,18 @@ packages: type-fest: 0.20.2 dev: false + /seroval-plugins@1.0.4(seroval@1.0.4): + resolution: {integrity: sha512-DQ2IK6oQVvy8k+c2V5x5YCtUa/GGGsUwUBNN9UqohrZ0rWdUapBFpNMYP1bCyRHoxOJjdKGl+dieacFIpU/i1A==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + dependencies: + seroval: 1.0.4 + + /seroval@1.0.4: + resolution: {integrity: sha512-qQs/N+KfJu83rmszFQaTxcoJoPn6KNUruX4KmnmyD0oZkUoiNvJ1rpdYKDf4YHM05k+HOgCxa3yvf15QbVijGg==} + engines: {node: '>=10'} + /serve-handler@6.1.5: resolution: {integrity: sha512-ijPFle6Hwe8zfmBxJdE+5fta53fdIY0lHISJvuikXB3VYFafRjMRpOffSPvCYsbKyBA7pvy9oYr/BT1O3EArlg==} dependencies: @@ -5943,6 +6110,56 @@ packages: ip: 2.0.0 smart-buffer: 4.2.0 + /solid-floating-ui@0.3.1(@floating-ui/dom@1.6.1)(solid-js@1.8.12): + resolution: {integrity: sha512-o/QmGsWPS2Z3KidAxP0nDvN7alI7Kqy0kU+wd85Fz+au5SYcnYm7I6Fk3M60Za35azsPX0U+5fEtqfOuk6Ao0Q==} + engines: {node: '>=10'} + peerDependencies: + '@floating-ui/dom': ^1.5 + solid-js: ^1.8 + dependencies: + '@floating-ui/dom': 1.6.1 + solid-js: 1.8.12 + dev: false + + /solid-js@1.8.12: + resolution: {integrity: sha512-sLE/i6M9FSWlov3a2pTC5ISzanH2aKwqXTZj+bbFt4SUrVb4iGEa7fpILBMOxsQjkv3eXqEk6JVLlogOdTe0UQ==} + dependencies: + csstype: 3.1.3 + seroval: 1.0.4 + seroval-plugins: 1.0.4(seroval@1.0.4) + + /solid-refresh@0.6.3(solid-js@1.8.12): + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + dependencies: + '@babel/generator': 7.23.6 + '@babel/helper-module-imports': 7.22.15 + '@babel/types': 7.23.6 + solid-js: 1.8.12 + dev: true + + /solid-styled-components@0.28.5(solid-js@1.8.12): + resolution: {integrity: sha512-vwTcdp76wZNnESIzB6rRZ3U55NgcSAQXCiiRIiEFhxTFqT0bEh/warNT1qaRZu4OkAzrBkViOngF35ktI8sc4A==} + peerDependencies: + solid-js: ^1.4.4 + dependencies: + csstype: 3.1.3 + goober: 2.1.14(csstype@3.1.3) + solid-js: 1.8.12 + dev: false + + /solid-transition-group@0.2.3(solid-js@1.8.12): + resolution: {integrity: sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==} + engines: {node: '>=18.0.0', pnpm: '>=8.6.0'} + peerDependencies: + solid-js: ^1.6.12 + dependencies: + '@solid-primitives/refs': 1.0.6(solid-js@1.8.12) + '@solid-primitives/transition-group': 1.0.4(solid-js@1.8.12) + solid-js: 1.8.12 + dev: false + /source-map-js@1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} @@ -6422,6 +6639,10 @@ packages: hasBin: true dev: false + /validate-html-nesting@1.2.2: + resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==} + dev: true + /validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} dependencies: @@ -6476,6 +6697,28 @@ packages: lib-esm: 0.4.2 dev: true + /vite-plugin-solid@2.9.1(solid-js@1.8.12)(vite@5.0.12): + resolution: {integrity: sha512-RC4hj+lbvljw57BbMGDApvEOPEh14lwrr/GeXRLNQLcR1qnOdzOwwTSFy13Gj/6FNIZpBEl0bWPU+VYFawrqUw==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + dependencies: + '@babel/core': 7.23.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.8.12(@babel/core@7.23.7) + merge-anything: 5.1.7 + solid-js: 1.8.12 + solid-refresh: 0.6.3(solid-js@1.8.12) + vite: 5.0.12(@types/node@20.11.0) + vitefu: 0.2.5(vite@5.0.12) + transitivePeerDependencies: + - supports-color + dev: true + /vite@5.0.12(@types/node@20.11.0): resolution: {integrity: sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==} engines: {node: ^18.0.0 || >=20.0.0} @@ -6512,6 +6755,17 @@ packages: fsevents: 2.3.3 dev: true + /vitefu@0.2.5(vite@5.0.12): + resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + vite: + optional: true + dependencies: + vite: 5.0.12(@types/node@20.11.0) + dev: true + /vudio@2.1.1(patch_hash=7iux5msqpgl3octdmwy4uspwoe): resolution: {integrity: sha512-VkFQcFt/b/kpF5Eg5Sq+oXUo1Zp5aRFF4BSmIrOzau5o+5WMWwX9ae/EGJZstCyZFiCTU5iw1Y+u2BCGW6Y6Jw==} dev: false diff --git a/src/plugins/in-app-menu/assets/close.svg b/src/plugins/in-app-menu/assets/close.svg deleted file mode 100644 index c43ee928a0..0000000000 --- a/src/plugins/in-app-menu/assets/close.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/plugins/in-app-menu/assets/maximize.svg b/src/plugins/in-app-menu/assets/maximize.svg deleted file mode 100644 index 3a8a0675f0..0000000000 --- a/src/plugins/in-app-menu/assets/maximize.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/plugins/in-app-menu/assets/menu.svg b/src/plugins/in-app-menu/assets/menu.svg deleted file mode 100644 index 49e40477ed..0000000000 --- a/src/plugins/in-app-menu/assets/menu.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/plugins/in-app-menu/assets/minimize.svg b/src/plugins/in-app-menu/assets/minimize.svg deleted file mode 100644 index 634e1637c2..0000000000 --- a/src/plugins/in-app-menu/assets/minimize.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/plugins/in-app-menu/assets/unmaximize.svg b/src/plugins/in-app-menu/assets/unmaximize.svg deleted file mode 100644 index 8466869b30..0000000000 --- a/src/plugins/in-app-menu/assets/unmaximize.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/plugins/in-app-menu/constants.ts b/src/plugins/in-app-menu/constants.ts new file mode 100644 index 0000000000..9390df6a5a --- /dev/null +++ b/src/plugins/in-app-menu/constants.ts @@ -0,0 +1,11 @@ +export interface InAppMenuConfig { + enabled: boolean; + hideDOMWindowControls: boolean; +} +export const defaultInAppMenuConfig: InAppMenuConfig = { + enabled: + (typeof window !== 'undefined' && + !window.navigator?.userAgent?.includes('mac')) || + (typeof global !== 'undefined' && global.process?.platform !== 'darwin'), + hideDOMWindowControls: false, +}; diff --git a/src/plugins/in-app-menu/index.ts b/src/plugins/in-app-menu/index.ts index 1277332a01..28106184c1 100644 --- a/src/plugins/in-app-menu/index.ts +++ b/src/plugins/in-app-menu/index.ts @@ -2,24 +2,15 @@ import titlebarStyle from './titlebar.css?inline'; import { createPlugin } from '@/utils'; import { onMainLoad } from './main'; import { onMenu } from './menu'; -import { onPlayerApiReady, onRendererLoad } from './renderer'; +import { onConfigChange, onPlayerApiReady, onRendererLoad } from './renderer'; import { t } from '@/i18n'; +import { defaultInAppMenuConfig } from './constants'; -export interface InAppMenuConfig { - enabled: boolean; - hideDOMWindowControls: boolean; -} export default createPlugin({ name: () => t('plugins.in-app-menu.name'), description: () => t('plugins.in-app-menu.description'), restartNeeded: true, - config: { - enabled: - (typeof window !== 'undefined' && - !window.navigator?.userAgent?.includes('mac')) || - (typeof global !== 'undefined' && global.process?.platform !== 'darwin'), - hideDOMWindowControls: false, - } as InAppMenuConfig, + config: defaultInAppMenuConfig, stylesheets: [titlebarStyle], menu: onMenu, @@ -27,5 +18,6 @@ export default createPlugin({ renderer: { start: onRendererLoad, onPlayerApiReady, + onConfigChange, }, }); diff --git a/src/plugins/in-app-menu/main.ts b/src/plugins/in-app-menu/main.ts index aef720ee3a..400558eabd 100644 --- a/src/plugins/in-app-menu/main.ts +++ b/src/plugins/in-app-menu/main.ts @@ -3,7 +3,7 @@ import { register } from 'electron-localshortcut'; import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron'; import type { BackendContext } from '@/types/contexts'; -import type { InAppMenuConfig } from './index'; +import type { InAppMenuConfig } from './constants'; export const onMainLoad = ({ window: win, diff --git a/src/plugins/in-app-menu/menu/icons.ts b/src/plugins/in-app-menu/menu/icons.ts deleted file mode 100644 index 892b0bc352..0000000000 --- a/src/plugins/in-app-menu/menu/icons.ts +++ /dev/null @@ -1,14 +0,0 @@ -const Icons = { - submenu: - '', - checkbox: - '', - radio: { - checked: - '', - unchecked: - '', - }, -}; - -export default Icons; diff --git a/src/plugins/in-app-menu/menu/panel.ts b/src/plugins/in-app-menu/menu/panel.ts deleted file mode 100644 index ea9d40ee4e..0000000000 --- a/src/plugins/in-app-menu/menu/panel.ts +++ /dev/null @@ -1,220 +0,0 @@ -import Icons from './icons'; - -import { ElementFromHtml } from '../../utils/renderer'; - -import type { MenuItem } from 'electron'; - -interface PanelOptions { - placement?: 'bottom' | 'right'; - order?: number; - openOnHover?: boolean; -} - -export const createPanel = ( - parent: HTMLElement, - anchor: HTMLElement, - items: MenuItem[], - options: PanelOptions = { placement: 'bottom', order: 0, openOnHover: false }, -) => { - const childPanels: HTMLElement[] = []; - const panel = document.createElement('menu-panel'); - panel.style.zIndex = `${options.order}`; - - const updateIconState = async (iconWrapper: HTMLElement, item: MenuItem) => { - if (item.type === 'checkbox') { - if (item.checked) iconWrapper.innerHTML = Icons.checkbox; - else iconWrapper.innerHTML = ''; - } else if (item.type === 'radio') { - if (item.checked) iconWrapper.innerHTML = Icons.radio.checked; - else iconWrapper.innerHTML = Icons.radio.unchecked; - } else { - const iconURL = - typeof item.icon === 'string' - ? ((await window.ipcRenderer.invoke( - 'image-path-to-data-url', - )) as string) - : item.icon?.toDataURL(); - - if (iconURL) iconWrapper.style.background = `url(${iconURL})`; - } - }; - - const radioGroups: [MenuItem, HTMLElement][] = []; - items.map((item) => { - if (!item.visible) return; - if (item.type === 'separator') - return panel.appendChild(document.createElement('menu-separator')); - - const menu = document.createElement('menu-item'); - const iconWrapper = document.createElement('menu-icon'); - - updateIconState(iconWrapper, item); - menu.appendChild(iconWrapper); - menu.append(item.label); - - if (item.sublabel) { - menu.classList.add('badge'); - const menuBadge = document.createElement('menu-item-badge'); - menuBadge.append(item.sublabel); - menu.append(menuBadge); - } - if (item.toolTip) { - const menuTooltip = document.createElement('menu-item-tooltip'); - menuTooltip.append(item.toolTip); - - menu.addEventListener('mouseenter', () => { - const rect = menu.getBoundingClientRect(); - menuTooltip.style.setProperty('max-width', `${rect.width - 8}px`); - menuTooltip.style.setProperty('--x', `${rect.left}px`); - menuTooltip.style.setProperty('--y', `${rect.top + rect.height}px`); - menuTooltip.classList.add('show'); - }); - menu.addEventListener('mouseleave', () => { - menuTooltip.classList.remove('show'); - }); - parent.append(menuTooltip); - } - - menu.addEventListener('click', async () => { - await window.ipcRenderer.invoke('ytmd:menu-event', item.commandId); - const menuItem = (await window.ipcRenderer.invoke( - 'get-menu-by-id', - item.commandId, - )) as MenuItem | null; - - if (menuItem) { - updateIconState(iconWrapper, menuItem); - - if (menuItem.type === 'radio') { - await Promise.all( - radioGroups.map(async ([item, iconWrapper]) => { - if (item.commandId === menuItem.commandId) return; - const newItem = (await window.ipcRenderer.invoke( - 'get-menu-by-id', - item.commandId, - )) as MenuItem | null; - - if (newItem) updateIconState(iconWrapper, newItem); - }), - ); - } - } - }); - - if (item.type === 'radio') { - radioGroups.push([item, iconWrapper]); - } - - if (item.type === 'submenu') { - const subMenuIcon = document.createElement('menu-icon'); - subMenuIcon.appendChild(ElementFromHtml(Icons.submenu)); - menu.appendChild(subMenuIcon); - - const [child, , children] = createPanel( - parent, - menu, - item.submenu?.items ?? [], - { - placement: 'right', - order: (options?.order ?? 0) + 1, - openOnHover: true, - }, - ); - - childPanels.push(child); - childPanels.push(...children); - } - - return panel.appendChild(menu); - }); - - /* methods */ - const isOpened = () => panel.getAttribute('open') === 'true'; - const close = () => panel.setAttribute('open', 'false'); - const open = () => { - const rect = anchor.getBoundingClientRect(); - - if (options.placement === 'bottom') { - panel.style.setProperty('--x', `${rect.x}px`); - panel.style.setProperty('--y', `${rect.y + rect.height}px`); - } else { - panel.style.setProperty('--x', `${rect.x + rect.width}px`); - panel.style.setProperty('--y', `${rect.y}px`); - } - - panel.setAttribute('open', 'true'); - - // Children are placed below their parent item, which can cause - // long lists to squeeze their children at the bottom of the screen - // (This needs to be done *after* setAttribute) - panel.classList.remove('position-by-bottom'); - if ( - options.placement === 'right' && - panel.scrollHeight > panel.clientHeight - ) { - panel.style.setProperty('--y', `${rect.y + rect.height}px`); - panel.classList.add('position-by-bottom'); - } - }; - - if (options.openOnHover) { - let timeout: number | null = null; - anchor.addEventListener('mouseenter', () => { - if (timeout) window.clearTimeout(timeout); - timeout = window.setTimeout(() => { - if (!isOpened()) open(); - }, 225); - }); - anchor.addEventListener('mouseleave', () => { - if (timeout) window.clearTimeout(timeout); - let mouseX = 0, mouseY = 0; - const onMouseMove = (event: MouseEvent) => { - mouseX = event.clientX; - mouseY = event.clientY; - }; - document.addEventListener('mousemove', onMouseMove); - timeout = window.setTimeout(() => { - document.removeEventListener('mousemove', onMouseMove); - const now = document.elementFromPoint(mouseX, mouseY); - if (now === panel || panel.contains(now)) { - const onLeave = () => { - document.addEventListener('mousemove', onMouseMove); - timeout = window.setTimeout(() => { - document.removeEventListener('mousemove', onMouseMove); - const now = document.elementFromPoint(mouseX, mouseY); - if (now === panel || panel.contains(now) || childPanels.some((it) => it.contains(now))) return; - - if (isOpened()) close(); - panel.removeEventListener('mouseleave', onLeave); - }, 225); - }; - panel.addEventListener('mouseleave', onLeave); - return; - } - - if (isOpened()) close(); - }, 225); - }); - } - - anchor.addEventListener('click', () => { - if (isOpened()) close(); - else open(); - }); - - document.body.addEventListener('click', (event) => { - const path = event.composedPath(); - const isInside = path.some( - (it) => - it === panel || - it === anchor || - childPanels.includes(it as HTMLElement), - ); - - if (!isInside) close(); - }); - - parent.appendChild(panel); - - return [panel, { isOpened, close, open }, childPanels] as const; -}; diff --git a/src/plugins/in-app-menu/renderer.ts b/src/plugins/in-app-menu/renderer.ts deleted file mode 100644 index 8be84ed4b6..0000000000 --- a/src/plugins/in-app-menu/renderer.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { createPanel } from './menu/panel'; - -import logoRaw from './assets/menu.svg?inline'; -import closeRaw from './assets/close.svg?inline'; -import minimizeRaw from './assets/minimize.svg?inline'; -import maximizeRaw from './assets/maximize.svg?inline'; -import unmaximizeRaw from './assets/unmaximize.svg?inline'; - -import type { Menu } from 'electron'; - -import type { RendererContext } from '@/types/contexts'; -import type { InAppMenuConfig } from '@/plugins/in-app-menu/index'; - -const isMacOS = navigator.userAgent.includes('Macintosh'); -const isNotWindowsOrMacOS = - !navigator.userAgent.includes('Windows') && !isMacOS; - -export const onRendererLoad = async ({ - getConfig, - ipc: { invoke, on }, -}: RendererContext) => { - const config = await getConfig(); - - const hideDOMWindowControls = config.hideDOMWindowControls; - - let hideMenu = window.mainConfig.get('options.hideMenu'); - const titleBar = document.createElement('title-bar'); - const navBar = document.querySelector('#nav-bar-background'); - let maximizeButton: HTMLButtonElement; - let panelClosers: (() => void)[] = []; - if (isMacOS) titleBar.style.setProperty('--offset-left', '70px'); - - const logo = document.createElement('img'); - const close = document.createElement('img'); - const minimize = document.createElement('img'); - const maximize = document.createElement('img'); - const unmaximize = document.createElement('img'); - - if (window.ELECTRON_RENDERER_URL) { - logo.src = window.ELECTRON_RENDERER_URL + '/' + logoRaw; - close.src = window.ELECTRON_RENDERER_URL + '/' + closeRaw; - minimize.src = window.ELECTRON_RENDERER_URL + '/' + minimizeRaw; - maximize.src = window.ELECTRON_RENDERER_URL + '/' + maximizeRaw; - unmaximize.src = window.ELECTRON_RENDERER_URL + '/' + unmaximizeRaw; - } else { - logo.src = logoRaw; - close.src = closeRaw; - minimize.src = minimizeRaw; - maximize.src = maximizeRaw; - unmaximize.src = unmaximizeRaw; - } - - logo.classList.add('title-bar-icon'); - const logoClick = () => { - hideMenu = !hideMenu; - let visibilityStyle: string; - if (hideMenu) { - visibilityStyle = 'hidden'; - } else { - visibilityStyle = 'visible'; - } - const menus = document.querySelectorAll('menu-button'); - menus.forEach((menu) => { - menu.style.visibility = visibilityStyle; - }); - }; - logo.onclick = logoClick; - - on('toggle-in-app-menu', logoClick); - - if (!isMacOS) titleBar.appendChild(logo); - document.body.appendChild(titleBar); - - const addWindowControls = async () => { - // Create window control buttons - const minimizeButton = document.createElement('button'); - minimizeButton.classList.add('window-control'); - minimizeButton.appendChild(minimize); - minimizeButton.onclick = () => invoke('window-minimize'); - - maximizeButton = document.createElement('button'); - if (await invoke('window-is-maximized')) { - maximizeButton.classList.add('window-control'); - maximizeButton.appendChild(unmaximize); - } else { - maximizeButton.classList.add('window-control'); - maximizeButton.appendChild(maximize); - } - maximizeButton.onclick = async () => { - if (await invoke('window-is-maximized')) { - // change icon to maximize - maximizeButton.removeChild(maximizeButton.firstChild!); - maximizeButton.appendChild(maximize); - - // call unmaximize - await invoke('window-unmaximize'); - } else { - // change icon to unmaximize - maximizeButton.removeChild(maximizeButton.firstChild!); - maximizeButton.appendChild(unmaximize); - - // call maximize - await invoke('window-maximize'); - } - }; - - const closeButton = document.createElement('button'); - closeButton.classList.add('window-control'); - closeButton.appendChild(close); - closeButton.onclick = () => invoke('window-close'); - - // Create a container div for the window control buttons - const windowControlsContainer = document.createElement('div'); - windowControlsContainer.classList.add('window-controls-container'); - windowControlsContainer.appendChild(minimizeButton); - windowControlsContainer.appendChild(maximizeButton); - windowControlsContainer.appendChild(closeButton); - - // Add window control buttons to the title bar - titleBar.appendChild(windowControlsContainer); - }; - - if (isNotWindowsOrMacOS && !hideDOMWindowControls) await addWindowControls(); - - if (navBar) { - const observer = new MutationObserver((mutations) => { - mutations.forEach(() => { - titleBar.style.setProperty( - '--titlebar-background-color', - navBar.style.backgroundColor, - ); - document - .querySelector('html')! - .style.setProperty( - '--titlebar-background-color', - navBar.style.backgroundColor, - ); - }); - }); - - observer.observe(navBar, { attributes: true, attributeFilter: ['style'] }); - } - - const updateMenu = async () => { - const children = [...titleBar.children]; - children.forEach((child) => { - if (child !== logo) child.remove(); - }); - panelClosers = []; - - const menu = (await invoke('get-menu')) as Menu | null; - if (!menu) return; - - menu.items.forEach((menuItem) => { - const menu = document.createElement('menu-button'); - const [, { close: closer }] = createPanel( - titleBar, - menu, - menuItem.submenu?.items ?? [], - ); - panelClosers.push(closer); - - menu.append(menuItem.label); - titleBar.appendChild(menu); - if (hideMenu) { - menu.style.visibility = 'hidden'; - } - }); - if (isNotWindowsOrMacOS && !hideDOMWindowControls) - await addWindowControls(); - }; - await updateMenu(); - - document.title = 'Youtube Music'; - - on('close-all-in-app-menu-panel', () => { - panelClosers.forEach((closer) => closer()); - }); - on('refresh-in-app-menu', () => updateMenu()); - on('window-maximize', () => { - if ( - isNotWindowsOrMacOS && - !hideDOMWindowControls && - maximizeButton.firstChild - ) { - maximizeButton.removeChild(maximizeButton.firstChild); - maximizeButton.appendChild(unmaximize); - } - }); - on('window-unmaximize', () => { - if ( - isNotWindowsOrMacOS && - !hideDOMWindowControls && - maximizeButton.firstChild - ) { - maximizeButton.removeChild(maximizeButton.firstChild); - maximizeButton.appendChild(unmaximize); - } - }); - - if (window.mainConfig.plugins.isEnabled('picture-in-picture')) { - on('pip-toggle', () => { - updateMenu(); - }); - } -}; - -export const onPlayerApiReady = () => { - const htmlHeadStyle = document.querySelector('head > div > style'); - if (htmlHeadStyle) { - // HACK: This is a hack to remove the scrollbar width - htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace( - 'html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', - 'html::-webkit-scrollbar {', - ); - } -}; diff --git a/src/plugins/in-app-menu/renderer.tsx b/src/plugins/in-app-menu/renderer.tsx new file mode 100644 index 0000000000..3fdb928af0 --- /dev/null +++ b/src/plugins/in-app-menu/renderer.tsx @@ -0,0 +1,57 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; + +import { TitleBar } from './renderer/TitleBar'; +import { defaultInAppMenuConfig, InAppMenuConfig } from './constants'; + +import type { RendererContext } from '@/types/contexts'; + +const scrollStyle = ` + html::-webkit-scrollbar { + background-color: red; + } +`; + +const isMacOS = navigator.userAgent.includes('Macintosh'); +const isNotWindowsOrMacOS = + !navigator.userAgent.includes('Windows') && !isMacOS; + + +const [config, setConfig] = createSignal(defaultInAppMenuConfig); +export const onRendererLoad = async ({ + getConfig, + ipc, +}: RendererContext) => { + setConfig(await getConfig()); + + document.title = 'YouTube Music'; + const stylesheet = new CSSStyleSheet(); + stylesheet.replaceSync(scrollStyle); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet]; + + render(() => ( + + ), document.body); +}; + +export const onPlayerApiReady = () => { + // NOT WORKING AFTER YTM UPDATE (last checked 2024-02-04) + // + // const htmlHeadStyle = document.querySelector('head > div > style'); + // if (htmlHeadStyle) { + // // HACK: This is a hack to remove the scrollbar width + // htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace( + // 'html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', + // 'html::-webkit-scrollbar { width: 0;', + // ); + // } +}; + +export const onConfigChange = (newConfig: InAppMenuConfig) => { + setConfig(newConfig); +}; diff --git a/src/plugins/in-app-menu/renderer/IconButton.tsx b/src/plugins/in-app-menu/renderer/IconButton.tsx new file mode 100644 index 0000000000..2b29ba5ffc --- /dev/null +++ b/src/plugins/in-app-menu/renderer/IconButton.tsx @@ -0,0 +1,40 @@ +import { JSX } from 'solid-js'; +import { css } from 'solid-styled-components'; + +const iconButton = css` + background: transparent; + + width: 24px; + height: 24px; + + padding: 2px; + border-radius: 2px; + + display: flex; + justify-content: center; + align-items: center; + + color: white; + + outline: none; + border: none; + + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + + &:active { + scale: 0.9; + } +`; + +type CollapseIconButtonProps = JSX.HTMLAttributes; +export const IconButton = (props: CollapseIconButtonProps) => { + return ( + + ); +}; diff --git a/src/plugins/in-app-menu/renderer/MenuButton.tsx b/src/plugins/in-app-menu/renderer/MenuButton.tsx new file mode 100644 index 0000000000..da17f84865 --- /dev/null +++ b/src/plugins/in-app-menu/renderer/MenuButton.tsx @@ -0,0 +1,42 @@ +import { JSX, splitProps } from 'solid-js'; +import { css } from 'solid-styled-components'; + +const menuStyle = css` + -webkit-app-region: none; + + display: flex; + justify-content: center; + align-items: center; + align-self: stretch; + + padding: 2px 8px; + border-radius: 4px; + + cursor: pointer; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + &:active { + scale: 0.9; + } + + &[data-selected="true"] { + background-color: rgba(255, 255, 255, 0.2); + } +`; + +export type MenuButtonProps = JSX.HTMLAttributes & { + text?: string; + selected?: boolean; +}; +export const MenuButton = (props: MenuButtonProps) => { + const [local, leftProps] = splitProps(props, ['text']); + + return ( +
  • + {local.text} +
  • + ); +}; diff --git a/src/plugins/in-app-menu/renderer/Panel.tsx b/src/plugins/in-app-menu/renderer/Panel.tsx new file mode 100644 index 0000000000..6701a0d15e --- /dev/null +++ b/src/plugins/in-app-menu/renderer/Panel.tsx @@ -0,0 +1,148 @@ +import { createSignal, JSX, Show, splitProps } from 'solid-js'; +import { mergeProps, Portal } from 'solid-js/web'; +import { css } from 'solid-styled-components'; +import { Transition } from 'solid-transition-group'; +import { autoUpdate, flip, offset, OffsetOptions, size } from '@floating-ui/dom'; +import { useFloating } from 'solid-floating-ui'; + +const panelStyle = css` + position: fixed; + top: var(--offset-y, 0); + left: var(--offset-x, 0); + + max-width: var(--max-width, 100%); + max-height: var(--max-height, 100%); + + z-index: 10000; + width: fit-content; + height: fit-content; + + padding: 4px; + box-sizing: border-box; + border-radius: 8px; + overflow: auto; + + background-color: color-mix( + in srgb, + var(--titlebar-background-color, #030303) 50%, + rgba(0, 0, 0, 0.1) + ); + backdrop-filter: blur(8px); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), + 0 2px 8px rgba(0, 0, 0, 0.2); + + transform-origin: var(--origin-x, 50%) var(--origin-y, 50%); +`; + +const animationStyle = { + enter: css` + opacity: 0; + transform: scale(0.9); + `, + enterActive: css` + transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1); + `, + exitTo: css` + opacity: 0; + transform: scale(0.9); + `, + exitActive: css` + transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0); + `, +}; + +export type Placement = + 'top' + | 'bottom' + | 'left' + | 'right' + | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + | 'right-start' + | 'right-end' + | 'left-start' + | 'left-end'; +export type PanelProps = JSX.HTMLAttributes & { + open?: boolean; + anchor?: HTMLElement | null; + children: JSX.Element; + + placement?: Placement; + offset?: OffsetOptions; +}; +export const Panel = (props: PanelProps) => { + const [elements, local, leftProps] = splitProps( + mergeProps({ placement: 'bottom' }, props), + ['anchor', 'children'], + ['open', 'placement', 'offset'], + ); + + const [panel, setPanel] = createSignal(null); + + const position = useFloating(() => elements.anchor, panel, { + whileElementsMounted: autoUpdate, + placement: local.placement as Placement, + strategy: 'fixed', + middleware: [ + offset(local.offset), + size({ + padding: 8, + apply({ elements, availableWidth, availableHeight }) { + elements.floating.style.setProperty('--max-width', `${Math.max(200, availableWidth)}px`); + elements.floating.style.setProperty('--max-height', `${Math.max(200, availableHeight)}px`); + } + }), + flip({ fallbackStrategy: 'initialPlacement' }), + ], + }); + + const originX = () => { + if (position.placement.includes('left')) return '100%'; + if (position.placement.includes('right')) return '0'; + if (position.placement.includes('top') || position.placement.includes('bottom')) { + if (position.placement.includes('start')) return '0'; + if (position.placement.includes('end')) return '100%'; + } + + return '50%'; + }; + const originY = () => { + if (position.placement.includes('top')) return '100%'; + if (position.placement.includes('bottom')) return '0'; + if (position.placement.includes('left') || position.placement.includes('right')) { + if (position.placement.includes('start')) return '0'; + if (position.placement.includes('end')) return '100%'; + } + return '50%'; + }; + + return ( + + + +
      + {elements.children} +
    +
    +
    +
    + ); +}; diff --git a/src/plugins/in-app-menu/renderer/PanelItem.tsx b/src/plugins/in-app-menu/renderer/PanelItem.tsx new file mode 100644 index 0000000000..ec11ce41f4 --- /dev/null +++ b/src/plugins/in-app-menu/renderer/PanelItem.tsx @@ -0,0 +1,331 @@ +import { createSignal, Match, Show, Switch } from 'solid-js'; +import { JSX } from 'solid-js/jsx-runtime'; +import { css } from 'solid-styled-components'; +import { Portal } from 'solid-js/web'; + +import { Transition } from 'solid-transition-group'; +import { useFloating } from 'solid-floating-ui'; +import { autoUpdate, offset, size } from '@floating-ui/dom'; + +import { Panel } from './Panel'; + +const itemStyle = css` + position: relative; + + -webkit-app-region: none; + min-height: 32px; + height: 32px; + + display: grid; + grid-template-columns: 32px 1fr auto minmax(32px, auto); + justify-content: flex-start; + align-items: center; + + border-radius: 4px; + cursor: pointer; + box-sizing: border-box; + user-select: none; + -webkit-user-drag: none; + + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + &:active { + background-color: rgba(255, 255, 255, 0.2); + } + + &[data-selected="true"] { + background-color: rgba(255, 255, 255, 0.2); + } + + & * { + box-sizing: border-box; + } +`; + +const itemIconStyle = css` + height: 32px; + padding: 4px; + color: white; +`; + +const itemLabelStyle = css` + font-size: 12px; + color: white; +`; + +const itemChipStyle = css` + display: flex; + justify-content: center; + align-items: center; + + min-width: 16px; + height: 16px; + padding: 0 4px; + margin-left: 8px; + + border-radius: 4px; + background-color: rgba(255, 255, 255, 0.2); + color: #f1f1f1; + font-size: 10px; + font-weight: 500; + line-height: 1; +`; + +const toolTipStyle = css` + min-width: 32px; + width: 100%; + height: 100%; + + padding: 4px; + + max-width: calc(var(--max-width, 100%) - 8px); + max-height: calc(var(--max-height, 100%) - 8px); + + border-radius: 4px; + background-color: rgba(25, 25, 25, 0.8); + color: #f1f1f1; + font-size: 10px; +`; + +const popupStyle = css` + position: fixed; + top: var(--offset-y, 0); + left: var(--offset-x, 0); + + max-width: var(--max-width, 100%); + max-height: var(--max-height, 100%); + + z-index: 100000000; + pointer-events: none; + +`; +const animationStyle = { + enter: css` + opacity: 0; + transform: scale(0.9); + `, + enterActive: css` + transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1); + `, + exitTo: css` + opacity: 0; + transform: scale(0.9); + `, + exitActive: css` + transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0); + `, +}; + +const getParents = (element: Element | null): (HTMLElement | null)[] => { + const parents: (HTMLElement | null)[] = []; + let now = element; + + while (now) { + parents.push(now as HTMLElement | null); + now = now.parentElement; + } + + return parents; +}; + +type BasePanelItemProps = { + name: string; + label?: string; + chip?: string; + toolTip?: string; + commandId?: number; +}; +type NormalPanelItemProps = BasePanelItemProps & { + type: 'normal'; + onClick?: () => void; +}; +type SubmenuItemProps = BasePanelItemProps & { + type: 'submenu'; + level: number[]; + children: JSX.Element; +}; +type RadioPanelItemProps = BasePanelItemProps & { + type: 'radio'; + checked: boolean; + onChange?: (checked: boolean) => void; +}; +type CheckboxPanelItemProps = BasePanelItemProps & { + type: 'checkbox'; + checked: boolean; + onChange?: (checked: boolean) => void; +}; +export type PanelItemProps = NormalPanelItemProps | SubmenuItemProps | RadioPanelItemProps | CheckboxPanelItemProps; +export const PanelItem = (props: PanelItemProps) => { + const [open, setOpen] = createSignal(false); + const [toolTipOpen, setToolTipOpen] = createSignal(false); + const [toolTip, setToolTip] = createSignal(null); + const [anchor, setAnchor] = createSignal(null); + const [child, setChild] = createSignal(null); + + const position = useFloating(anchor, toolTip, { + whileElementsMounted: autoUpdate, + placement: 'bottom-start', + strategy: 'fixed', + middleware: [ + offset({ mainAxis: 8 }), + size({ + apply({ rects, elements }) { + elements.floating.style.setProperty('--max-width', `${rects.reference.width}px`); + } + }), + ], + }); + + const handleHover = (event: MouseEvent) => { + setToolTipOpen(true); + event.target?.addEventListener('mouseleave', () => { + setToolTipOpen(false); + }, { once: true }); + + if (props.type === 'submenu') { + const timer = setTimeout(() => { + setOpen(true); + + let mouseX = event.clientX; + let mouseY = event.clientY; + const onMouseMove = (event: MouseEvent) => { + mouseX = event.clientX; + mouseY = event.clientY; + }; + document.addEventListener('mousemove', onMouseMove); + + event.target?.addEventListener('mouseleave', () => { + setTimeout(() => { + document.removeEventListener('mousemove', onMouseMove); + const parents = getParents(document.elementFromPoint(mouseX, mouseY)); + + if (!parents.includes(child())) { + setOpen(false); + } else { + const onOtherHover = (event: MouseEvent) => { + const parents = getParents(event.target as HTMLElement); + const closestLevel = parents.find((it) => it?.dataset?.level)?.dataset.level ?? ''; + const path = event.composedPath(); + + const isOtherItem = path.some((it) => it instanceof HTMLElement && it.classList.contains(itemStyle)); + const isChild = closestLevel.startsWith(props.level.join('/')); + + if (isOtherItem && !isChild) { + setOpen(false); + document.removeEventListener('mousemove', onOtherHover); + } + }; + document.addEventListener('mousemove', onOtherHover); + } + }, 225); + }, { once: true }); + }, 225); + + event.target?.addEventListener('mouseleave', () => { + clearTimeout(timer); + }, { once: true }); + } + }; + + const handleClick = async () => { + await window.ipcRenderer.invoke('ytmd:menu-event', props.commandId); + if (props.type === 'radio') { + props.onChange?.(!props.checked); + } else if (props.type === 'checkbox') { + props.onChange?.(!props.checked); + } else if (props.type === 'normal') { + props.onClick?.(); + } + }; + + + return ( +
  • + }> + + + + + + + + + + + + + + + + + + + {props.name} + + }> + + {props.chip} + + + + + + + + + {props.type === 'submenu' && props.children} + + + + +
    + + +
    + {props.toolTip} +
    +
    +
    +
    +
    +
    +
  • + ); +}; diff --git a/src/plugins/in-app-menu/renderer/TitleBar.tsx b/src/plugins/in-app-menu/renderer/TitleBar.tsx new file mode 100644 index 0000000000..58a8db5854 --- /dev/null +++ b/src/plugins/in-app-menu/renderer/TitleBar.tsx @@ -0,0 +1,342 @@ +import { Menu, MenuItem } from 'electron'; +import { createEffect, createResource, createSignal, Index, Match, onMount, Show, Switch } from 'solid-js'; +import { css } from 'solid-styled-components'; +import { TransitionGroup } from 'solid-transition-group'; + +import { MenuButton } from './MenuButton'; +import { Panel } from './Panel'; +import { PanelItem } from './PanelItem'; +import { IconButton } from './IconButton'; +import { WindowController } from './WindowController'; + +import type { RendererContext } from '@/types/contexts'; +import type { InAppMenuConfig } from '../constants'; + +const titleStyle = css` + -webkit-app-region: drag; + box-sizing: border-box; + + position: fixed; + top: 0; + z-index: 10000000; + + width: 100%; + height: var(--menu-bar-height, 32px); + + display: flex; + flex-flow: row; + justify-content: flex-start; + align-items: center; + gap: 4px; + + color: #f1f1f1; + font-size: 12px; + padding: 4px 4px 4px var(--offset-left, 4px); + background-color: var(--titlebar-background-color, #030303); + user-select: none; + + transition: opacity 200ms ease 0s, + background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s; + + &[data-macos="true"] { + padding: 4px 4px 4px 74px; + } +`; + +const separatorStyle = css` + min-height: 1px; + height: 1px; + margin: 4px 0; + + background-color: rgba(255, 255, 255, 0.2); +`; + +const animationStyle = { + enter: css` + opacity: 0; + transform: translateX(-50%) scale(0.8); + `, + enterActive: css` + transition: opacity 0.1s cubic-bezier(0.33, 1, 0.68, 1), transform 0.1s cubic-bezier(0.33, 1, 0.68, 1); + `, + exitTo: css` + opacity: 0; + transform: translateX(-50%) scale(0.8); + `, + exitActive: css` + transition: opacity 0.1s cubic-bezier(0.32, 0, 0.67, 0), transform 0.1s cubic-bezier(0.32, 0, 0.67, 0); + `, + move: css` + transition: all 0.1s cubic-bezier(0.65, 0, 0.35, 1); + `, + fakeTarget: css` + position: absolute; + opacity: 0; + `, + fake: css` + transition: all 0.00000000001s; + `, +}; + +export type PanelRendererProps = { + items: Electron.Menu['items']; + level?: number[]; + onClick?: (commandId: number, radioGroup?: MenuItem[]) => void; +} +const PanelRenderer = (props: PanelRendererProps) => { + const radioGroup = () => props.items.filter((it) => it.type === 'radio'); + + return ( + + {(subItem) => ( + + + + props.onClick?.(subItem().commandId)} + /> + + + + + + + + props.onClick?.(subItem().commandId)} + /> + + + props.onClick?.(subItem().commandId, radioGroup())} + /> + + +
    +
    +
    +
    + )} +
    + ); +}; + +export type TitleBarProps = { + ipc: RendererContext['ipc']; + isMacOS?: boolean; + enableController?: boolean; + initialCollapsed?: boolean; +}; +export const TitleBar = (props: TitleBarProps) => { + const [collapsed, setCollapsed] = createSignal(props.initialCollapsed); + const [ignoreTransition, setIgnoreTransition] = createSignal(false); + const [openTarget, setOpenTarget] = createSignal(null); + const [menu, setMenu] = createSignal(null); + + const [data, { refetch }] = createResource(async () => await props.ipc.invoke('get-menu') as Promise); + const [isMaximized, { refetch: refetchMaximize }] = createResource(async () => await props.ipc.invoke('window-is-maximized') as Promise); + + const handleToggleMaximize = async () => { + if (isMaximized()) { + await props.ipc.invoke('window-unmaximize'); + } else { + await props.ipc.invoke('window-maximize'); + } + await refetchMaximize(); + }; + const handleMinimize = async () => { + await props.ipc.invoke('window-minimize'); + }; + const handleClose = async () => { + await props.ipc.invoke('window-close'); + }; + + const refreshMenuItem = async (originalMenu: Menu, commandId: number) => { + const menuItem = (await window.ipcRenderer.invoke( + 'get-menu-by-id', + commandId, + )) as MenuItem | null; + + const newMenu = structuredClone(originalMenu); + const stack = [...newMenu?.items ?? []]; + let now: MenuItem | undefined = stack.pop(); + while (now) { + const index = now?.submenu?.items?.findIndex((it) => it.commandId === commandId) ?? -1; + + if (index >= 0) { + if (menuItem) now?.submenu?.items?.splice(index, 1, menuItem); + else now?.submenu?.items?.splice(index, 1); + } + if (now?.submenu) { + stack.push(...now.submenu.items); + } + + now = stack.pop(); + } + + return newMenu; + }; + + const handleItemClick = async (commandId: number, radioGroup?: MenuItem[]) => { + const menuData = menu(); + if (!menuData) return; + + if (Array.isArray(radioGroup)) { + let newMenu = menuData; + for await (const item of radioGroup) { + newMenu = await refreshMenuItem(newMenu, item.commandId); + } + + setMenu(newMenu); + return; + } + + setMenu(await refreshMenuItem(menuData, commandId)); + }; + + onMount(() => { + props.ipc.on('close-all-in-app-menu-panel', async () => { + setIgnoreTransition(true); + setMenu(null); + await refetch(); + setMenu(data() ?? null); + setIgnoreTransition(false); + }); + props.ipc.on('refresh-in-app-menu', async () => { + setIgnoreTransition(true); + await refetch(); + setMenu(data() ?? null); + setIgnoreTransition(false); + }); + props.ipc.on('toggle-in-app-menu', () => { + setCollapsed(!collapsed()); + }); + + props.ipc.on('window-maximize', refetchMaximize); + props.ipc.on('window-unmaximize', refetchMaximize); + }); + + createEffect(() => { + if (!menu() && data()) { + setMenu(data() ?? null); + } + }); + + return ( + + ); +}; + diff --git a/src/plugins/in-app-menu/renderer/WindowController.tsx b/src/plugins/in-app-menu/renderer/WindowController.tsx new file mode 100644 index 0000000000..9549c6f943 --- /dev/null +++ b/src/plugins/in-app-menu/renderer/WindowController.tsx @@ -0,0 +1,65 @@ +import { css } from 'solid-styled-components'; +import { Show } from 'solid-js'; + +import { IconButton } from './IconButton'; + +const containerStyle = css` + display: flex; + justify-content: flex-end; + align-items: center; + + & > *:last-of-type { + border-top-right-radius: 4px; + + &:hover { + background: rgba(255, 0, 0, 0.5); + } + } +`; + +export type WindowControllerProps = { + isMaximize?: boolean; + + onToggleMaximize?: () => void; + onMinimize?: () => void; + onClose?: () => void; +} +export const WindowController = (props: WindowControllerProps) => { + return ( +
    + + + + + + + + + + } + > + + + + + + + + + + +
    + ); +}; diff --git a/src/plugins/in-app-menu/titlebar.css b/src/plugins/in-app-menu/titlebar.css index 56e8d514a6..2614a52995 100644 --- a/src/plugins/in-app-menu/titlebar.css +++ b/src/plugins/in-app-menu/titlebar.css @@ -31,7 +31,7 @@ title-bar { background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s; } -menu-button { +.menu-button { -webkit-app-region: none; display: flex; @@ -44,11 +44,11 @@ menu-button { cursor: pointer; } -menu-button:hover { +.menu-button:hover { background-color: rgba(255, 255, 255, 0.1); } -menu-panel { +.menu-panel { position: fixed; top: var(--y, 0); left: var(--x, 0); @@ -84,18 +84,18 @@ menu-panel { opacity 200ms ease 0s, transform 200ms ease 0s; } -menu-panel[open='true'] { +.menu-panel[open='true'] { pointer-events: all; opacity: 1; transform: scale(1); } -menu-panel.position-by-bottom { +.menu-panel.position-by-bottom { top: unset; bottom: calc(100vh - var(--y, 100%)); max-height: calc(var(--y, 0) - var(--menu-bar-height, 36px) - 16px); } -menu-item { +.menu-item { position: relative; -webkit-app-region: none; @@ -110,21 +110,21 @@ menu-item { border-radius: 4px; cursor: pointer; } -menu-item.badge { +.menu-item.badge { grid-template-columns: 32px 1fr auto minmax(32px, auto); } -menu-item:hover { +.menu-item:hover { background-color: rgba(255, 255, 255, 0.1); } -menu-item > menu-icon { +.menu-item > .menu-icon { height: 32px; padding: 4px; box-sizing: border-box; } -menu-separator { +.menu-separator { min-height: 1px; height: 1px; margin: 4px 0; @@ -132,7 +132,7 @@ menu-separator { background-color: rgba(255, 255, 255, 0.2); } -menu-item-badge { +.menu-item-badge { display: flex; justify-content: center; align-items: center; @@ -150,7 +150,7 @@ menu-item-badge { line-height: 1; } -menu-item-tooltip { +.menu-item-tooltip { position: fixed; left: var(--x, 0); @@ -177,7 +177,7 @@ menu-item-tooltip { transform-origin: 50% 0; transition: opacity 0.225s ease-out, scale 0.225s ease-out; } -menu-item-tooltip.show { +.menu-item-tooltip.show { opacity: 1; scale: 1.0; } diff --git a/tsconfig.json b/tsconfig.json index e34d17fb93..ea24384a38 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,8 @@ "esModuleInterop": true, "resolveJsonModule": true, "moduleResolution": "node", + "jsx": "preserve", + "jsxImportSource": "solid-js", "baseUrl": ".", "outDir": "./dist", "strict": true,