Compare commits

...

13 Commits
2.3.2 ... 2.3.3

Author SHA1 Message Date
wj-xiao
4fcc4c3747 release: nanokvm@2.3.3 2026-01-16 15:32:14 +08:00
wj-xiao
0d2c52adc3 feat: the menu bar supports setting the display mode 2026-01-16 11:47:56 +08:00
wj-xiao
af256e7f92 fix: compatible with IME composition 2026-01-15 17:04:17 +08:00
wj-xiao
84830421ee fix: compatible with Windows AltGr key 2026-01-15 16:45:48 +08:00
wj-xiao
9b278cdb0e fix: compatible with macOS's command key combinations 2026-01-14 17:58:25 +08:00
wj-xiao
9b40a5cf86 chore: update eslint 2026-01-14 17:58:25 +08:00
wj-xiao
b8d980be33 fix: the IntlBackslash key does not work on German virtual keyboard 2026-01-14 17:58:25 +08:00
wenjie
195037af87 Merge pull request #728 from imguoguo/main
ci: add missing parameters and push when issue closed
2026-01-14 15:48:07 +08:00
Guoguo
c98000069a ci: add missing parameters and push when issue closed 2026-01-14 07:43:19 +00:00
wenjie
2bd277b57d Merge pull request #725 from kmw0410/main
update korean translation
2026-01-14 10:30:40 +08:00
wj-xiao
4852ed813b chore: bump react-router-dom from 6.27.0 to 6.30.3 2026-01-14 10:27:08 +08:00
wj-xiao
fb08df9e4e fix: correct erroneous release date 2026-01-12 19:53:26 +08:00
kmw0410
64f3b49e21 update korean translation 2026-01-12 09:11:56 +09:00
22 changed files with 752 additions and 158 deletions

View File

@@ -2,7 +2,7 @@ name: Issue Tracker
on:
issues:
types: [opened, reopened]
types: [opened, reopened, closed]
jobs:
track-issue:
@@ -28,8 +28,10 @@ jobs:
--arg state "${{ github.event.issue.state }}" \
--arg created_at "${{ github.event.issue.created_at }}" \
--arg updated_at "${{ github.event.issue.updated_at }}" \
--arg closed_at "${{ github.event.issue.closed_at }}" \
--arg url "${{ github.event.issue.html_url }}" \
--arg user_login "${{ github.event.issue.user.login }}" \
--arg repository "${{ github.repository }}" \
--argjson labels "$RAW_LABELS" \
--argjson assignees "$RAW_ASSIGNEES" \
'{
@@ -42,11 +44,13 @@ jobs:
state: $state,
created_at: $created_at,
updated_at: $updated_at,
closed_at: $closed_at,
url: $url,
user: { login: $user_login },
labels: $labels,
assignees: $assignees
}
},
repository: { name: $repository },
}')
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$API_URL" \

View File

@@ -1,4 +1,26 @@
## 2.3.2 (2025-01-08)
## 2.3.3 (2026-01-16)
### Features
* Added support for setting the display mode of the menu bar
### Bug Fixes
* Fixed a keyboard issue where IME composition events were not handled correctly
* Resolved an issue where the `AltGr` key was not recognized on the Windows system
* Fixed a bug where `Command` key combinations were not fully released on the macOS system
* Fixed input issues with the `IntlBackslash` key on the German virtual keyboard
### Localization
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
### Chores
* Bump react-router-dom from 6.27.0 to 6.30.3
* Updated eslint
## 2.3.2 (2026-01-08)
### Features

View File

@@ -1,20 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

32
web/eslint.config.js Normal file
View File

@@ -0,0 +1,32 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
export default tseslint.config(
{ ignores: ['dist', 'public/mockServiceWorker.js'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
eslintConfigPrettier
)

View File

@@ -7,7 +7,7 @@
"dev": "vite",
"mocked": "vite --mode mocked",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint": "eslint . --report-unused-disable-directives",
"preview": "vite preview"
},
"dependencies": {
@@ -31,7 +31,7 @@
"react-helmet-async": "^2.0.5",
"react-i18next": "^14.1.3",
"react-responsive": "^10.0.0",
"react-router-dom": "^6.27.0",
"react-router-dom": "^6.30.3",
"react-simple-keyboard": "^3.8.19",
"semver": "^7.6.3",
"vaul": "^0.9.9",
@@ -39,6 +39,7 @@
"yocto-queue": "^1.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
@@ -55,12 +56,14 @@
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^17.0.0",
"msw": "^2.6.0",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.18",
"typescript": "^5.6.3",
"typescript-eslint": "^8.53.0",
"vite": "7.1.11",
"vite-tsconfig-paths": "^4.3.2"
},
@@ -69,4 +72,4 @@
"public"
]
}
}
}

251
web/pnpm-lock.yaml generated
View File

@@ -69,8 +69,8 @@ importers:
specifier: ^10.0.0
version: 10.0.0(react@18.3.1)
react-router-dom:
specifier: ^6.27.0
version: 6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: ^6.30.3
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-simple-keyboard:
specifier: ^3.8.19
version: 3.8.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -87,6 +87,9 @@ importers:
specifier: ^1.2.1
version: 1.2.1
devDependencies:
'@eslint/js':
specifier: ^9.39.2
version: 9.39.2
'@ianvs/prettier-plugin-sort-imports':
specifier: ^4.7.0
version: 4.7.0(prettier@3.4.2)
@@ -135,6 +138,9 @@ importers:
eslint-plugin-react-refresh:
specifier: ^0.4.16
version: 0.4.16(eslint@9.39.1(jiti@1.21.7))
globals:
specifier: ^17.0.0
version: 17.0.0
msw:
specifier: ^2.6.0
version: 2.6.0(@types/node@22.9.0)(typescript@5.6.3)
@@ -153,6 +159,9 @@ importers:
typescript:
specifier: ^5.6.3
version: 5.6.3
typescript-eslint:
specifier: ^8.53.0
version: 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
vite:
specifier: 7.1.11
version: 7.1.11(@types/node@22.9.0)(jiti@1.21.7)(yaml@2.6.0)
@@ -498,10 +507,20 @@ packages:
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/regexpp@4.12.1':
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint-community/regexpp@4.12.2':
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/config-array@0.21.1':
resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -522,6 +541,10 @@ packages:
resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.39.2':
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.7':
resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -959,8 +982,8 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
'@remix-run/router@1.20.0':
resolution: {integrity: sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==}
'@remix-run/router@1.23.2':
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
engines: {node: '>=14.0.0'}
'@rolldown/pluginutils@1.0.0-beta.27':
@@ -1138,6 +1161,14 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/eslint-plugin@8.53.0':
resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.53.0
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.48.0':
resolution: {integrity: sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1145,22 +1176,45 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.53.0':
resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.48.0':
resolution: {integrity: sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.53.0':
resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.48.0':
resolution: {integrity: sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/scope-manager@8.53.0':
resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.48.0':
resolution: {integrity: sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/tsconfig-utils@8.53.0':
resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.48.0':
resolution: {integrity: sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1168,16 +1222,33 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.53.0':
resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.48.0':
resolution: {integrity: sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.53.0':
resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.48.0':
resolution: {integrity: sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/typescript-estree@8.53.0':
resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.48.0':
resolution: {integrity: sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1185,10 +1256,21 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.53.0':
resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.48.0':
resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/visitor-keys@8.53.0':
resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -1787,6 +1869,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
globals@17.0.0:
resolution: {integrity: sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==}
engines: {node: '>=18'}
globalthis@1.0.4:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
@@ -2705,15 +2791,15 @@ packages:
peerDependencies:
react: '>=16.8.0'
react-router-dom@6.27.0:
resolution: {integrity: sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==}
react-router-dom@6.30.3:
resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.27.0:
resolution: {integrity: sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==}
react-router@6.30.3:
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
@@ -2814,6 +2900,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -2941,6 +3032,12 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -2991,6 +3088,13 @@ packages:
typedarray-to-buffer@3.1.5:
resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==}
typescript-eslint@8.53.0:
resolution: {integrity: sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
typescript@5.6.3:
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
engines: {node: '>=14.17'}
@@ -3498,8 +3602,15 @@ snapshots:
eslint: 9.39.1(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.1(jiti@1.21.7))':
dependencies:
eslint: 9.39.1(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {}
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.21.1':
dependencies:
'@eslint/object-schema': 2.1.7
@@ -3532,6 +3643,8 @@ snapshots:
'@eslint/js@9.39.1': {}
'@eslint/js@9.39.2': {}
'@eslint/object-schema@2.1.7': {}
'@eslint/plugin-kit@0.4.1':
@@ -3933,7 +4046,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@remix-run/router@1.20.0': {}
'@remix-run/router@1.23.2': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
@@ -4078,6 +4191,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
'@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/type-utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
'@typescript-eslint/utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
'@typescript-eslint/visitor-keys': 8.53.0
eslint: 9.39.1(jiti@1.21.7)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.6.3)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.48.0
@@ -4090,6 +4219,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
'@typescript-eslint/visitor-keys': 8.53.0
debug: 4.4.3
eslint: 9.39.1(jiti@1.21.7)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.48.0(typescript@5.6.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.6.3)
@@ -4099,15 +4240,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.53.0(typescript@5.6.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.6.3)
'@typescript-eslint/types': 8.53.0
debug: 4.4.3
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.48.0':
dependencies:
'@typescript-eslint/types': 8.48.0
'@typescript-eslint/visitor-keys': 8.48.0
'@typescript-eslint/scope-manager@8.53.0':
dependencies:
'@typescript-eslint/types': 8.53.0
'@typescript-eslint/visitor-keys': 8.53.0
'@typescript-eslint/tsconfig-utils@8.48.0(typescript@5.6.3)':
dependencies:
typescript: 5.6.3
'@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.6.3)':
dependencies:
typescript: 5.6.3
'@typescript-eslint/type-utils@8.48.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@typescript-eslint/types': 8.48.0
@@ -4120,8 +4279,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
'@typescript-eslint/utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
debug: 4.4.3
eslint: 9.39.1(jiti@1.21.7)
ts-api-utils: 2.4.0(typescript@5.6.3)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.48.0': {}
'@typescript-eslint/types@8.53.0': {}
'@typescript-eslint/typescript-estree@8.48.0(typescript@5.6.3)':
dependencies:
'@typescript-eslint/project-service': 8.48.0(typescript@5.6.3)
@@ -4137,6 +4310,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.53.0(typescript@5.6.3)':
dependencies:
'@typescript-eslint/project-service': 8.53.0(typescript@5.6.3)
'@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.6.3)
'@typescript-eslint/types': 8.53.0
'@typescript-eslint/visitor-keys': 8.53.0
debug: 4.4.3
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.6.3)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.48.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
@@ -4148,11 +4336,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@1.21.7))
'@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
eslint: 9.39.1(jiti@1.21.7)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.48.0':
dependencies:
'@typescript-eslint/types': 8.48.0
eslint-visitor-keys: 4.2.1
'@typescript-eslint/visitor-keys@8.53.0':
dependencies:
'@typescript-eslint/types': 8.53.0
eslint-visitor-keys: 4.2.1
'@vitejs/plugin-react@4.7.0(vite@7.1.11(@types/node@22.9.0)(jiti@1.21.7)(yaml@2.6.0))':
dependencies:
'@babel/core': 7.28.5
@@ -4952,6 +5156,8 @@ snapshots:
globals@14.0.0: {}
globals@17.0.0: {}
globalthis@1.0.4:
dependencies:
define-properties: 1.2.1
@@ -5833,16 +6039,16 @@ snapshots:
react: 18.3.1
shallow-equal: 3.1.0
react-router-dom@6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@remix-run/router': 1.20.0
'@remix-run/router': 1.23.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-router: 6.27.0(react@18.3.1)
react-router: 6.30.3(react@18.3.1)
react-router@6.27.0(react@18.3.1):
react-router@6.30.3(react@18.3.1):
dependencies:
'@remix-run/router': 1.20.0
'@remix-run/router': 1.23.2
react: 18.3.1
react-simple-keyboard@3.8.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -5969,6 +6175,8 @@ snapshots:
semver@7.6.3: {}
semver@7.7.3: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -6141,6 +6349,10 @@ snapshots:
dependencies:
typescript: 5.6.3
ts-api-utils@2.4.0(typescript@5.6.3):
dependencies:
typescript: 5.6.3
ts-interface-checker@0.1.13: {}
tsconfck@3.1.4(typescript@5.6.3):
@@ -6195,6 +6407,17 @@ snapshots:
dependencies:
is-typedarray: 1.0.0
typescript-eslint@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
'@typescript-eslint/parser': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
'@typescript-eslint/utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
eslint: 9.39.1(jiti@1.21.7)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
typescript@5.6.3: {}
unbox-primitive@1.0.2:

View File

@@ -0,0 +1,55 @@
import { RefObject, useCallback, useEffect, useState } from 'react';
import { useAtomValue } from 'jotai';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
export interface MenuBounds {
left: number;
right: number;
top: number;
bottom: number;
}
export function useMenuBounds(
nodeRef: RefObject<HTMLDivElement | null>,
isMenuExpanded: boolean
): MenuBounds {
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
const [menuBounds, setMenuBounds] = useState<MenuBounds>({
left: 0,
right: 0,
top: 0,
bottom: 0
});
const handleResize = useCallback(() => {
if (!nodeRef.current) return;
const elementRect = nodeRef.current.getBoundingClientRect();
const width = (window.innerWidth - elementRect.width) / 2;
setMenuBounds({
left: -width,
top: -10,
right: width,
bottom: window.innerHeight - elementRect.height - 10
});
}, [nodeRef]);
// Update bounds on mount and resize
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
// Update bounds when menu items or expansion state change
useEffect(() => {
handleResize();
}, [menuDisabledItems, isMenuExpanded, handleResize]);
return menuBounds;
}

View File

@@ -0,0 +1,129 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { getMenuDisabledItems, getMenuDisplayMode } from '@/lib/localstorage.ts';
import {
menuDisabledItemsAtom,
menuDisplayModeAtom,
submenuOpenCountAtom
} from '@/jotai/settings.ts';
const HIDE_TIMEOUT = 5000;
export interface MenuVisibilityState {
isInitialized: boolean;
isMenuExpanded: boolean;
isMenuHidden: boolean;
isMenuMoved: boolean;
isInvisible: boolean;
handleHovered: (hovered: boolean) => void;
handleMoved: () => void;
setIsMenuExpanded: (expanded: boolean) => void;
}
export function useMenuVisibility(): MenuVisibilityState {
const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom);
const setMenuDisabledItems = useSetAtom(menuDisabledItemsAtom);
const submenuOpenCount = useAtomValue(submenuOpenCountAtom);
const [isMenuExpanded, setIsMenuExpanded] = useState(true);
const [isMenuMoved, setIsMenuMoved] = useState(false);
const [isMenuHovered, setIsMenuHovered] = useState(false);
const [isMenuHidden, setIsMenuHidden] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startCountdown = useCallback(() => {
if (menuDisplayMode !== 'auto' || submenuOpenCount > 0 || !isMenuExpanded || isMenuMoved) {
return;
}
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setIsMenuHidden(true);
}, HIDE_TIMEOUT);
}, [menuDisplayMode, submenuOpenCount, isMenuExpanded, isMenuMoved]);
const stopCountdown = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
// Initialize menu settings
useEffect(() => {
const displayMode = getMenuDisplayMode();
setMenuDisplayMode(displayMode);
const items = getMenuDisabledItems();
setMenuDisabledItems(items);
if (displayMode === 'off') {
setIsMenuExpanded(false);
}
setIsInitialized(true);
return () => {
stopCountdown();
};
}, []);
// Handle display mode changes
useEffect(() => {
if (menuDisplayMode === 'off') {
setIsMenuExpanded(false);
setIsMenuHidden(false);
stopCountdown();
} else if (menuDisplayMode === 'always') {
setIsMenuExpanded(true);
setIsMenuHidden(false);
stopCountdown();
} else if (menuDisplayMode === 'auto') {
setIsMenuExpanded(true);
setIsMenuHidden(false);
startCountdown();
}
}, [menuDisplayMode, startCountdown, stopCountdown]);
// Handle hover and submenu state
useEffect(() => {
if (submenuOpenCount === 0 && !isMenuHovered) {
startCountdown();
} else {
stopCountdown();
}
}, [submenuOpenCount, isMenuHovered, startCountdown, stopCountdown]);
// Handle hover state
const handleHovered = useCallback((hovered: boolean) => {
setIsMenuHovered(hovered);
if (hovered) {
setIsMenuHidden(false);
}
}, []);
const handleMoved = useCallback(() => {
if (!isMenuMoved) {
setIsMenuMoved(true);
}
}, [isMenuMoved]);
const isInvisible = menuDisplayMode === 'off' && !isMenuExpanded;
return {
isMenuExpanded,
isMenuHidden,
isMenuMoved,
isInvisible,
isInitialized,
handleHovered,
handleMoved,
setIsMenuExpanded
};
}

View File

@@ -243,10 +243,18 @@ const en = {
display: 'Display',
language: 'Language',
languageDesc: 'Select the language for the interface',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar',
webTitle: 'Web Title',
webTitleDesc: 'Customize the web page title'
webTitleDesc: 'Customize the web page title',
menuBar: {
title: 'Menu Bar',
mode: 'Display Mode',
modeDesc: 'Display menu bar on the screen',
modeOff: 'Off',
modeAuto: 'Auto hide',
modeAlways: 'Always visible',
icons: 'Submenu Icons',
iconsDesc: 'Display submenu icons in the menu bar'
}
},
device: {
title: 'Device',

View File

@@ -217,6 +217,7 @@ const ko = {
title: '디자인',
display: '표시',
language: '언어',
languageDesc: '인터페이스 언어를 선택하세요',
menuBar: '메뉴 바',
menuBarDesc: '메뉴 바에 아이콘을 표시',
webTitle: '웹 제목',
@@ -336,7 +337,14 @@ const ko = {
cancel: '취소',
preview: '미리보기 업데이트',
previewDesc: '새로운 기능과 개선 사항에 미리 접근하세요',
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!'
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!',
offline: {
title: '오프라인 업데이트',
desc: '로컬 설치 패키지를 통한 업데이트',
upload: '업로드',
invalidName: '유효하지 않은 파일 이름 형식입니다. GitHub 릴리즈에서 다운로드하세요.',
updateFailed: '업데이트에 실패했습니다. 재시도하세요.'
}
},
account: {
title: '계정',

View File

@@ -94,7 +94,7 @@ const tr = {
resetHid: 'İnsan Arayüz Cihazı\'nı sıfırlayın',
hidOnly: {
title: 'Sadece İnsan Arayüz Cihazı modu',
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı\'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
tip1: 'Sadece İnsan Arayüz Cihazı modunu aktifleştirmek, sanal U-disk’i ve sanal ağı devre dışı bırakacaktır.',
tip2: 'Sadece İnsan Arayüz Cihazı modunda disk imajı bağlama özelliği devre dışı bırakılır',
tip3: 'NanoKVM mod değişiminden sonra kendiliğinden yeniden başlatılacaktır',

View File

@@ -212,10 +212,18 @@ const zh = {
display: '显示',
language: '语言',
languageDesc: '选择界面语言',
menuBar: '菜单栏',
menuBarDesc: '是否在菜单栏中显示图标',
webTitle: '网页标题',
webTitleDesc: '自定义网站页面标题'
webTitleDesc: '自定义网站页面标题',
menuBar: {
title: '菜单栏',
mode: '显示方式',
modeDesc: '菜单栏在屏幕上的显示方式',
modeOff: '关闭',
modeAuto: '自动隐藏',
modeAlways: '始终显示',
icons: '菜单图标',
iconsDesc: '是否在菜单栏中显示子菜单图标'
}
},
device: {
title: '设备',

View File

@@ -8,3 +8,6 @@ export const submenuOpenCountAtom = atom(0);
// web title
export const webTitleAtom = atom('');
// menu display mode: 'off' | 'auto' | 'always'
export const menuDisplayModeAtom = atom<string>('auto');

56
web/src/lib/browser.ts Normal file
View File

@@ -0,0 +1,56 @@
export interface BrowserInfo {
os: string;
isChrome: boolean;
}
export function getOperatingSystem(): string {
if (typeof window === 'undefined') {
return 'Unknown';
}
const platformSource =
// @ts-expect-error check userAgentData.platform
'userAgentData' in navigator ? navigator.userAgentData.platform : navigator.platform;
if (!platformSource) {
return 'Unknown';
}
const platform = platformSource.toLowerCase();
if (platform.startsWith('win')) return 'Windows';
if (platform.startsWith('mac')) return 'macOS';
if (platform.startsWith('linux')) return 'Linux';
return platformSource;
}
export function isChromeBrowser(): boolean {
if (typeof window === 'undefined' || !window.navigator) {
return false;
}
if ('userAgentData' in navigator) {
// @ts-expect-error userAgentData.brands
return navigator.userAgentData?.brands?.some(
(brand: any) => brand.brand === 'Google Chrome' || brand.brand === 'Chromium'
);
}
const userAgent = navigator.userAgent;
const vendor = navigator.vendor;
return (
/Chrome|Chromium/.test(userAgent) &&
/Google Inc/.test(vendor) &&
!/Edg/.test(userAgent) &&
!/OPR/.test(userAgent)
);
}
export function getBrowserInfo(): BrowserInfo {
return {
os: getOperatingSystem(),
isChrome: isChromeBrowser()
};
}

View File

@@ -17,6 +17,7 @@ const KEYBOARD_SYSTEM_KEY = 'nano-kvm-keyboard-system';
const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language';
const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password';
const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items';
const MENU_AUTO_HIDE_KEY = 'nano-kvm-menu-auto-hide';
const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm';
type ItemWithExpiry = {
@@ -212,6 +213,15 @@ export function getMenuDisabledItems(): string[] {
return value ? JSON.parse(value) : [];
}
export function getMenuDisplayMode(): string {
const value = localStorage.getItem(MENU_AUTO_HIDE_KEY);
return value || 'auto';
}
export function setMenuDisplayMode(mode: string) {
localStorage.setItem(MENU_AUTO_HIDE_KEY, mode);
}
export function getPowerConfirm() {
const enabled = localStorage.getItem(POWER_CONFIRM_KEY);
return enabled === 'true';

View File

@@ -1,17 +1,32 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { getOperatingSystem } from '@/lib/browser.ts';
import { KeyboardReport } from '@/lib/keyboard.ts';
import { isModifier } from '@/lib/keymap';
import { client, MessageEvent } from '@/lib/websocket.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
interface AltGrState {
active: boolean;
ctrlLeftTimestamp: number;
}
const ALTGR_THRESHOLD_MS = 10;
export const Keyboard = () => {
const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom);
const keyboardRef = useRef(new KeyboardReport());
const pressedKeys = useRef(new Set<string>());
const altGrState = useRef<AltGrState | null>(null);
const isComposing = useRef(false);
useEffect(() => {
if (getOperatingSystem() === 'Windows' && !altGrState.current) {
altGrState.current = { active: false, ctrlLeftTimestamp: 0 };
}
if (!isKeyboardEnabled) {
releaseKeys();
return;
@@ -19,6 +34,8 @@ export const Keyboard = () => {
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
document.addEventListener('compositionstart', handleCompositionStart);
document.addEventListener('compositionend', handleCompositionEnd);
window.addEventListener('blur', handleBlur);
document.addEventListener('visibilitychange', handleVisibilityChange);
@@ -26,12 +43,27 @@ export const Keyboard = () => {
function handleKeyDown(event: KeyboardEvent) {
if (!isKeyboardEnabled) return;
// Skip during IME composition
if (isComposing.current || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
const code = event.code;
if (pressedKeys.current.has(code)) {
return;
if (pressedKeys.current.has(code)) return;
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
if (altGrState.current) {
if (code === 'ControlLeft') {
altGrState.current.ctrlLeftTimestamp = event.timeStamp;
} else if (code === 'AltRight') {
const timeDiff = event.timeStamp - altGrState.current.ctrlLeftTimestamp;
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
pressedKeys.current.delete('ControlLeft');
handleKeyEvent({ type: 'keyup', code: 'ControlLeft' });
altGrState.current.active = true;
}
}
}
pressedKeys.current.add(code);
@@ -42,14 +74,51 @@ export const Keyboard = () => {
function handleKeyUp(event: KeyboardEvent) {
if (!isKeyboardEnabled) return;
if (isComposing.current || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
const code = event.code;
// Handle AltGr state for Windows
if (altGrState.current?.active) {
if (code === 'ControlLeft') return;
if (code === 'AltRight') {
altGrState.current.active = false;
}
}
// Compatible with macOS's command key combinations
if (code === 'MetaLeft' || code === 'MetaRight') {
const keysToRelease: string[] = [];
pressedKeys.current.forEach((pressedCode) => {
if (!isModifier(pressedCode)) {
keysToRelease.push(pressedCode);
}
});
for (const key of keysToRelease) {
handleKeyEvent({ type: 'keyup', code: key });
pressedKeys.current.delete(key);
}
}
pressedKeys.current.delete(code);
handleKeyEvent({ type: 'keyup', code });
}
// Composition start event
function handleCompositionStart() {
isComposing.current = true;
}
// Composition end event
function handleCompositionEnd() {
isComposing.current = false;
}
// Release all keys when window loses focus
function handleBlur() {
releaseKeys();
@@ -70,6 +139,12 @@ export const Keyboard = () => {
pressedKeys.current.clear();
// Reset AltGr state
if (altGrState.current) {
altGrState.current.active = false;
altGrState.current.ctrlLeftTimestamp = 0;
}
const report = keyboardRef.current.reset();
sendReport(report);
}
@@ -77,8 +152,12 @@ export const Keyboard = () => {
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keyup', handleKeyUp);
document.removeEventListener('compositionstart', handleCompositionStart);
document.removeEventListener('compositionend', handleCompositionEnd);
window.removeEventListener('blur', handleBlur);
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseKeys();
};
}, [isKeyboardEnabled]);

View File

@@ -1,12 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import { useRef } from 'react';
import { Divider } from 'antd';
import clsx from 'clsx';
import { useAtom, useAtomValue } from 'jotai';
import { useAtomValue } from 'jotai';
import { GripVerticalIcon } from 'lucide-react';
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
import { getMenuDisabledItems } from '@/lib/localstorage.ts';
import { menuDisabledItemsAtom, submenuOpenCountAtom } from '@/jotai/settings.ts';
import { menuDisabledItemsAtom, menuDisplayModeAtom } from '@/jotai/settings.ts';
import { useMenuBounds } from '@/hooks/useMenuBounds.ts';
import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts';
import { DownloadImage } from './download.tsx';
import { Fullscreen } from './fullscreen';
@@ -21,99 +22,27 @@ import { Settings } from './settings';
import { Terminal } from './terminal';
import { Wol } from './wol';
const HIDE_TIMEOUT = 8000;
export const Menu = () => {
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
const submenuOpenCount = useAtomValue(submenuOpenCountAtom);
const [isMenuExpanded, setIsMenuExpanded] = useState(true);
const [isMenuMoved, setIsMenuMoved] = useState(false);
const [isMenuHovered, setIsMenuHovered] = useState(false);
const [isMenuHidden, setIsMenuHidden] = useState(false);
const [menuBounds, setMenuBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
const nodeRef = useRef<HTMLDivElement | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// init menu
useEffect(() => {
const items = getMenuDisabledItems();
setMenuDisabledItems(items);
const menuDisplayMode = useAtomValue(menuDisplayModeAtom);
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
startCountdown();
handleResize();
const {
isInitialized,
isMenuExpanded,
isMenuHidden,
isInvisible,
handleHovered,
handleMoved,
setIsMenuExpanded
} = useMenuVisibility();
window.addEventListener('resize', handleResize);
const menuBounds = useMenuBounds(nodeRef, isMenuExpanded);
return () => {
stopCountdown();
window.removeEventListener('resize', handleResize);
};
}, []);
// handle menu bounds
useEffect(() => {
handleResize();
}, [menuDisabledItems, isMenuExpanded]);
// handle hide timer
useEffect(() => {
if (submenuOpenCount === 0 && !isMenuHovered) {
startCountdown();
return;
}
stopCountdown();
}, [submenuOpenCount, isMenuHovered]);
function handleResize() {
if (!nodeRef.current) return;
const elementRect = nodeRef.current.getBoundingClientRect();
const width = (window.innerWidth - elementRect.width) / 2;
setMenuBounds({
left: -width,
top: -10,
right: width,
bottom: window.innerHeight - elementRect.height - 10
});
}
function startCountdown() {
if (submenuOpenCount > 0 || !isMenuExpanded || isMenuMoved) {
return;
}
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setIsMenuHidden(true);
}, HIDE_TIMEOUT);
}
function stopCountdown() {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}
function handleMoved(_e: DraggableEvent, data: DraggableData) {
function onDragStop(_e: DraggableEvent, data: DraggableData) {
if (data.x === 0 && data.y === 0) return;
if (isMenuMoved) return;
setIsMenuMoved(true);
}
function handleHovered(hovered: boolean) {
setIsMenuHovered(hovered);
if (hovered) {
setIsMenuHidden(false);
}
handleMoved();
}
function isEnabled(item: string) {
@@ -126,11 +55,15 @@ export const Menu = () => {
bounds={menuBounds}
handle="strong"
positionOffset={{ x: '-50%', y: '0%' }}
onStop={handleMoved}
onStop={onDragStop}
>
<div
ref={nodeRef}
className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2"
className={clsx(
'fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2 transition-opacity duration-300',
isInitialized ? 'opacity-100' : 'opacity-0',
isInvisible && 'invisible'
)}
onMouseEnter={() => handleHovered(true)}
onMouseLeave={() => handleHovered(false)}
onBlur={() => handleHovered(false)}
@@ -144,7 +77,6 @@ export const Menu = () => {
<div className="sticky top-[10px] flex w-full justify-center">
<div
className={clsx(
'h-[36px] items-center rounded bg-neutral-800/80 transition-all duration-300',
'h-[36px] items-center rounded bg-neutral-800/80 pl-1 pr-2 transition-all duration-300',
isMenuExpanded ? 'flex' : 'hidden',
isMenuHidden ? '-translate-y-[110%] opacity-80' : 'translate-y-0 opacity-100'
@@ -186,7 +118,7 @@ export const Menu = () => {
</div>
{/* Menubar expand button */}
{!isMenuExpanded && <Expand toggleMenu={setIsMenuExpanded} />}
{!isMenuExpanded && menuDisplayMode !== 'off' && <Expand toggleMenu={setIsMenuExpanded} />}
</div>
</Draggable>
);

View File

@@ -2,7 +2,8 @@ import { Divider } from 'antd';
import { useTranslation } from 'react-i18next';
import { Language } from './language.tsx';
import { MenuBar } from './menu-bar.tsx';
import { MenuIcons } from './menu-icons.tsx';
import { MenuMode } from './menu-mode.tsx';
import { WebTitle } from './web-title.tsx';
export const Appearance = () => {
@@ -19,7 +20,9 @@ export const Appearance = () => {
<Divider className="opacity-50" style={{ margin: '32px 0' }} />
<MenuBar />
<div className="text-neutral-400">{t('settings.appearance.menuBar.title')}</div>
<MenuMode />
<MenuIcons />
</>
);
};

View File

@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
import * as ls from '@/lib/localstorage.ts';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
export const MenuBar = () => {
export const MenuIcons = () => {
const { t } = useTranslation();
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
@@ -43,13 +43,15 @@ export const MenuBar = () => {
}
return (
<>
<div className="mt-8 flex flex-col space-y-5">
<div className="flex flex-col">
<span className="text-neutral-400">{t('settings.appearance.menuBar')}</span>
<span className="text-xs text-neutral-500">{t('settings.appearance.menuBarDesc')}</span>
<span className="text-neutral-400">{t('settings.appearance.menuBar.icons')}</span>
<span className="text-xs text-neutral-500">
{t('settings.appearance.menuBar.iconsDesc')}
</span>
</div>
<div className="mt-8 flex flex-col space-y-5">
<div className="mt-5 flex flex-col space-y-5">
{items.map((item) => (
<div key={item.key} className="flex items-center justify-between">
<div className="flex items-center space-x-2 text-neutral-400">
@@ -66,6 +68,6 @@ export const MenuBar = () => {
</div>
))}
</div>
</>
</div>
);
};

View File

@@ -0,0 +1,38 @@
import { Segmented } from 'antd';
import { useAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import * as storage from '@/lib/localstorage.ts';
import { menuDisplayModeAtom } from '@/jotai/settings.ts';
export const MenuMode = () => {
const { t } = useTranslation();
const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom);
const options = [
// { value: 'off', label: t('settings.appearance.menuBar.modeOff') },
{ value: 'auto', label: t('settings.appearance.menuBar.modeAuto') },
{ value: 'always', label: t('settings.appearance.menuBar.modeAlways') }
];
function handleChange(mode: string) {
if (mode === menuDisplayMode) return;
setMenuDisplayMode(mode);
storage.setMenuDisplayMode(mode);
}
return (
<div className="mt-5 flex w-full items-center justify-between">
<div className="flex flex-col">
<span className="text-neutral-400">{t('settings.appearance.menuBar.mode')}</span>
<span className="text-xs text-neutral-500">
{t('settings.appearance.menuBar.modeDesc')}
</span>
</div>
<Segmented value={menuDisplayMode} options={options} onChange={handleChange} />
</div>
);
};

View File

@@ -137,7 +137,6 @@ export const VirtualKeyboard = () => {
// Tausch
if (base === 'KeyZ') return getKeycode('KeyY');
if (base === 'KeyY') return getKeycode('KeyZ');
if (base === 'IntlBackslash') return getKeycode('IntlBackslash_qwertz');
// all other labels use their own code
return getKeycode(base);
}

View File

@@ -79,11 +79,11 @@ export const keyboardOptions = {
KeyY_qwertz: 'Y',
KeyZ_qwertz: 'Z',
KeyQ_qwertz: 'Q<br/>@',
KeyE_qwertz: 'E<br/>€',
BracketRight_qwertz: '+~*',
Backslash_qwertz: '#<br/>\'',
Backslash_qwertz: "#<br/>'",
IntlBackslash_qwertz: '<|>',
Comma_qwertz: ',<br/>;',
Period_qwertz: '.<br/>:',
@@ -91,11 +91,11 @@ export const keyboardOptions = {
'{controlleft_qwertz}': 'STRG',
'{altright_qwertz}': 'ALT GR',
'{controlright_qwertz}': 'STRG',
BracketLeft_qwertz: 'Ü',
Semicolon_qwertz: 'Ö',
Quote_qwertz: 'Ä',
'{tab}': 'Tab',
KeyQ: 'Q',
KeyW: 'W',
@@ -336,7 +336,7 @@ export const modifierKeys = [
'{altright}',
'{metaright}',
'{winright}',
// DE-spezifische Modifier-Tokens (für qwertz-Layout)
'{controlleft_qwertz}',
'{controlright_qwertz}',