mirror of
https://github.com/sipeed/NanoKVM-USB.git
synced 2026-09-10 21:49:56 -05:00
release the browser version code
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
9
browser/.editorconfig
Normal file
9
browser/.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
20
browser/.eslintrc.cjs
Normal file
20
browser/.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
27
browser/.gitignore
vendored
Normal file
27
browser/.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
vite.config.js
|
||||||
1
browser/.prettierignore
Normal file
1
browser/.prettierignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.hbs
|
||||||
27
browser/.prettierrc.yaml
Normal file
27
browser/.prettierrc.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
singleQuote: true
|
||||||
|
trailingComma: none
|
||||||
|
printWidth: 100
|
||||||
|
tabWidth: 2
|
||||||
|
bracketSpacing: true
|
||||||
|
importOrder:
|
||||||
|
- ^(react/(.*)$)|^(react$)
|
||||||
|
- ^(next/(.*)$)|^(next$)
|
||||||
|
- <THIRD_PARTY_MODULES>
|
||||||
|
- ''
|
||||||
|
- ^types$
|
||||||
|
- ^@/(.*)$
|
||||||
|
- ''
|
||||||
|
- '^[./]'
|
||||||
|
importOrderSeparation: false
|
||||||
|
importOrderSortSpecifiers: true
|
||||||
|
importOrderBuiltinModulesToTop: true
|
||||||
|
importOrderParserPlugins:
|
||||||
|
- typescript
|
||||||
|
- jsx
|
||||||
|
- tsx
|
||||||
|
- decorators-legacy
|
||||||
|
importOrderMergeDuplicateImports: true
|
||||||
|
importOrderCombineTypeAndValueImports: true
|
||||||
|
plugins:
|
||||||
|
- '@ianvs/prettier-plugin-sort-imports'
|
||||||
|
- prettier-plugin-tailwindcss
|
||||||
0
browser/README.md
Normal file
0
browser/README.md
Normal file
13
browser/index.html
Normal file
13
browser/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/sipeed.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NanoKVM-USB</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
browser/package.json
Normal file
50
browser/package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "nanokvm-usb",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"antd": "^5.22.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"i18next": "^24.0.5",
|
||||||
|
"jotai": "^2.10.3",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^15.1.4",
|
||||||
|
"react-responsive": "^10.0.0",
|
||||||
|
"react-simple-keyboard": "^3.8.28",
|
||||||
|
"vaul": "^1.1.1",
|
||||||
|
"yocto-queue": "^1.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.15.0",
|
||||||
|
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.16.0",
|
||||||
|
"@typescript-eslint/parser": "^8.16.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.15.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-react": "^7.37.2",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.14",
|
||||||
|
"globals": "^15.12.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"prettier": "^3.4.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"typescript-eslint": "^8.15.0",
|
||||||
|
"vite": "^6.0.1",
|
||||||
|
"vite-tsconfig-paths": "^5.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
5196
browser/pnpm-lock.yaml
generated
Normal file
5196
browser/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
browser/postcss.config.js
Normal file
6
browser/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
BIN
browser/public/sipeed.ico
Normal file
BIN
browser/public/sipeed.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
120
browser/src/App.tsx
Normal file
120
browser/src/App.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Result, Spin } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMediaQuery } from 'react-responsive';
|
||||||
|
|
||||||
|
import { DeviceModal } from '@/components/device-modal';
|
||||||
|
import { Keyboard } from '@/components/keyboard';
|
||||||
|
import { Menu } from '@/components/menu';
|
||||||
|
import { Mouse } from '@/components/mouse';
|
||||||
|
import { VirtualKeyboard } from '@/components/virtual-keyboard';
|
||||||
|
import { resolutionAtom, serialStateAtom, videoStateAtom } from '@/jotai/device.ts';
|
||||||
|
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||||
|
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||||
|
import { camera } from '@/libs/camera';
|
||||||
|
import { device } from '@/libs/device';
|
||||||
|
import * as storage from '@/libs/storage';
|
||||||
|
import type { Resolution } from '@/types.ts';
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isBigScreen = useMediaQuery({ minWidth: 850 });
|
||||||
|
|
||||||
|
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||||
|
const videoState = useAtomValue(videoStateAtom);
|
||||||
|
const serialState = useAtomValue(serialStateAtom);
|
||||||
|
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
|
||||||
|
const setResolution = useSetAtom(resolutionAtom);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isCameraAvailable, setIsCameraAvailable] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resolution = storage.getVideoResolution();
|
||||||
|
if (resolution) {
|
||||||
|
setResolution(resolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMediaPermissions(resolution);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
camera.close();
|
||||||
|
device.serialPort.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function requestMediaPermissions(resolution?: Resolution) {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
width: { ideal: resolution?.width || 1920 },
|
||||||
|
height: { ideal: resolution?.height || 1080 }
|
||||||
|
},
|
||||||
|
audio: true
|
||||||
|
});
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
|
||||||
|
setIsCameraAvailable(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log('failed to request media permissions: ', err);
|
||||||
|
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||||
|
setIsCameraAvailable(false);
|
||||||
|
} else {
|
||||||
|
setIsCameraAvailable(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Spin size="large" spinning={isLoading} tip={t('camera.tip')} fullscreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCameraAvailable) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="info"
|
||||||
|
title={t('camera.denied')}
|
||||||
|
extra={[<h2 className="text-xl text-white">{t('camera.authorize')}</h2>]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DeviceModal />
|
||||||
|
|
||||||
|
{videoState === 'connected' && (
|
||||||
|
<>
|
||||||
|
<Menu />
|
||||||
|
|
||||||
|
{serialState === 'notSupported' && (
|
||||||
|
<Alert message={t('serial.notSupported')} type="warning" banner closable />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{serialState === 'connected' && (
|
||||||
|
<>
|
||||||
|
<Mouse />
|
||||||
|
{isKeyboardEnable && <Keyboard />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<video
|
||||||
|
id="video"
|
||||||
|
className={clsx('block select-none', mouseStyle)}
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VirtualKeyboard isBigScreen={isBigScreen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
14
browser/src/assets/index.css
Normal file
14
browser/src/assets/index.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@layer tailwind-base, antd;
|
||||||
|
|
||||||
|
@layer tailwind-base {
|
||||||
|
@tailwind base;
|
||||||
|
}
|
||||||
|
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
105
browser/src/assets/keyboard.css
Normal file
105
browser/src/assets/keyboard.css
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
.keyboardContainer {
|
||||||
|
display: flex;
|
||||||
|
background-color: #e5e5e5;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard.hg-theme-default {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-main.simple-keyboard {
|
||||||
|
width: 640px;
|
||||||
|
min-width: 640px;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-main.simple-keyboard .hg-button {
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-main.simple-keyboard .hg-row:first-child {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-arrows.simple-keyboard {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard .hg-button.selectedButton {
|
||||||
|
background: rgba(5, 25, 70, 0.53);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard .hg-button.emptySpace {
|
||||||
|
pointer-events: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-arrows .hg-row {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-arrows .hg-button {
|
||||||
|
width: 50px;
|
||||||
|
flex-grow: 0;
|
||||||
|
justify-content: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlArrows {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-control.simple-keyboard {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-control.simple-keyboard .hg-row:first-child {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard-control .hg-button {
|
||||||
|
width: 50px;
|
||||||
|
flex-grow: 0;
|
||||||
|
justify-content: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hg-button.hg-functionBtn.hg-button-space {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hg-layout-mac .hg-button.hg-functionBtn.hg-button-space {
|
||||||
|
width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard .hg-highlight {
|
||||||
|
background: rgb(37 99 235);
|
||||||
|
border-bottom: 1px solid #2563eb;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(37 99 235 / 0.1), 0 1px 2px -1px rgb(37 99 235 / 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-keyboard .hg-button.hg-double{
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-header {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
}
|
||||||
150
browser/src/components/device-modal/index.tsx
Normal file
150
browser/src/components/device-modal/index.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Modal, Select } from 'antd';
|
||||||
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolutionAtom,
|
||||||
|
serialStateAtom,
|
||||||
|
videoDeviceIdAtom,
|
||||||
|
videoStateAtom
|
||||||
|
} from '@/jotai/device.ts';
|
||||||
|
import { camera } from '@/libs/camera';
|
||||||
|
import { device } from '@/libs/device';
|
||||||
|
import * as storage from '@/libs/storage';
|
||||||
|
|
||||||
|
type MediaDevice = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeviceModal = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const resolution = useAtomValue(resolutionAtom);
|
||||||
|
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom);
|
||||||
|
const [videoState, setVideoState] = useAtom(videoStateAtom);
|
||||||
|
const [serialState, setSerialState] = useAtom(serialStateAtom);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [devices, setDevices] = useState<MediaDevice[]>([]);
|
||||||
|
const [errMsg, setErrMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getVideoDevices(false);
|
||||||
|
checkSerial();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoState === 'connected') {
|
||||||
|
if (serialState === 'notSupported' || serialState === 'connected') {
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
|
}, [videoState, serialState]);
|
||||||
|
|
||||||
|
// get video input devices
|
||||||
|
async function getVideoDevices(autoOpen: boolean) {
|
||||||
|
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput');
|
||||||
|
|
||||||
|
setDevices(videoDevices.map((device) => ({ value: device.deviceId, label: device.label })));
|
||||||
|
|
||||||
|
if (autoOpen) {
|
||||||
|
const deviceId = storage.getVideoDevice();
|
||||||
|
if (deviceId && videoDevices.some((device) => device.deviceId === deviceId)) {
|
||||||
|
await selectVideo(deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// select video input device
|
||||||
|
async function selectVideo(deviceId: string) {
|
||||||
|
if (videoState === 'connecting') return;
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
setVideoDeviceId('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoState('connecting');
|
||||||
|
setErrMsg('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await camera.open(deviceId, resolution.width, resolution.height);
|
||||||
|
if (!success) return;
|
||||||
|
|
||||||
|
const video = document.getElementById('video') as HTMLVideoElement;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
video.srcObject = camera.getStream();
|
||||||
|
|
||||||
|
setVideoState('connected');
|
||||||
|
setVideoDeviceId(deviceId);
|
||||||
|
storage.setVideoDevice(deviceId);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
setSerialState('disconnected');
|
||||||
|
setErrMsg(t('camera.failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check the web serial api
|
||||||
|
function checkSerial() {
|
||||||
|
const isWebSerialSupported = 'serial' in navigator;
|
||||||
|
|
||||||
|
const state = isWebSerialSupported ? 'disconnected' : 'notSupported';
|
||||||
|
setSerialState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// select serial port
|
||||||
|
async function selectSerial() {
|
||||||
|
if (serialState === 'connecting') return;
|
||||||
|
setSerialState('connecting');
|
||||||
|
setErrMsg('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const port = await navigator.serial.requestPort();
|
||||||
|
await device.serialPort.init(port);
|
||||||
|
|
||||||
|
setSerialState('connected');
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
setSerialState('disconnected');
|
||||||
|
setErrMsg(t('serial.failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isOpen} title={t('modal.title')} footer={null} closable={false} destroyOnClose>
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-5 py-10">
|
||||||
|
<Select
|
||||||
|
value={videoDeviceId || undefined}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
options={devices}
|
||||||
|
allowClear={true}
|
||||||
|
loading={videoState === 'connecting'}
|
||||||
|
placeholder={t('modal.selectVideo')}
|
||||||
|
onChange={selectVideo}
|
||||||
|
onClick={() => getVideoDevices(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{serialState !== 'notSupported' && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
className="w-[250px]"
|
||||||
|
loading={serialState === 'connecting'}
|
||||||
|
onClick={selectSerial}
|
||||||
|
>
|
||||||
|
{t('modal.selectSerial')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errMsg && <span className="text-xs text-red-500">{errMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
94
browser/src/components/keyboard/index.tsx
Normal file
94
browser/src/components/keyboard/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { device } from '@/libs/device';
|
||||||
|
import { Modifiers } from '@/libs/device/keyboard.ts';
|
||||||
|
import { KeyboardCodes } from '@/libs/keyboard';
|
||||||
|
|
||||||
|
export const Keyboard = () => {
|
||||||
|
const controlKeys = new Set(['Control', 'Shift', 'Alt', 'Meta']);
|
||||||
|
|
||||||
|
const lastKeyRef = useRef<KeyboardEvent>();
|
||||||
|
const pressedKeysRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// listen keyboard events
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// press button
|
||||||
|
async function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
lastKeyRef.current = event;
|
||||||
|
|
||||||
|
if (controlKeys.has(event.key)) {
|
||||||
|
pressedKeysRef.current.add(event.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendKeyDown(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// release button
|
||||||
|
async function handleKeyUp(event: KeyboardEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (controlKeys.has(event.key) && lastKeyRef.current?.code === event.code) {
|
||||||
|
await sendKeyDown(lastKeyRef.current);
|
||||||
|
|
||||||
|
lastKeyRef.current = undefined;
|
||||||
|
pressedKeysRef.current.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendKeyUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendKeyDown(event: KeyboardEvent) {
|
||||||
|
const code = KeyboardCodes.get(event.code);
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
const ctrl = getCtrl(event);
|
||||||
|
const keys = [0x00, 0x00, code, 0x00, 0x00, 0x00];
|
||||||
|
|
||||||
|
await device.sendKeyboardData(ctrl, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendKeyUp() {
|
||||||
|
const modifiers = new Modifiers();
|
||||||
|
const keys = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||||
|
await device.sendKeyboardData(modifiers, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCtrl(event: KeyboardEvent) {
|
||||||
|
const modifiers = new Modifiers();
|
||||||
|
|
||||||
|
if (event.ctrlKey) {
|
||||||
|
modifiers.leftCtrl = pressedKeysRef.current.has('ControlLeft');
|
||||||
|
modifiers.rightCtrl = pressedKeysRef.current.has('ControlRight');
|
||||||
|
}
|
||||||
|
if (event.shiftKey) {
|
||||||
|
modifiers.leftShift = pressedKeysRef.current.has('ShiftLeft');
|
||||||
|
modifiers.rightShift = pressedKeysRef.current.has('ShiftRight');
|
||||||
|
}
|
||||||
|
if (event.altKey) {
|
||||||
|
modifiers.leftAlt = pressedKeysRef.current.has('AltLeft');
|
||||||
|
modifiers.rightAlt = pressedKeysRef.current.has('AltRight');
|
||||||
|
}
|
||||||
|
if (event.metaKey) {
|
||||||
|
modifiers.leftWindows = pressedKeysRef.current.has('MetaLeft');
|
||||||
|
modifiers.rightWindows = pressedKeysRef.current.has('MetaRight');
|
||||||
|
}
|
||||||
|
|
||||||
|
return modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
38
browser/src/components/menu/fullscreen/index.tsx
Normal file
38
browser/src/components/menu/fullscreen/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { MaximizeIcon, MinimizeIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export const Fullscreen = () => {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onFullscreenChange() {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFullscreenChange();
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
const element = document.documentElement;
|
||||||
|
element.requestFullscreen().then();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().then();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70"
|
||||||
|
onClick={handleFullscreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <MinimizeIcon size={18} /> : <MaximizeIcon size={18} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
79
browser/src/components/menu/index.tsx
Normal file
79
browser/src/components/menu/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Divider } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { MenuIcon, XIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { serialStateAtom } from '@/jotai/device.ts';
|
||||||
|
import * as storage from '@/libs/storage';
|
||||||
|
|
||||||
|
import { Fullscreen } from './fullscreen';
|
||||||
|
import { Keyboard } from './keyboard';
|
||||||
|
import { Mouse } from './mouse';
|
||||||
|
import { SerialPort } from './serial-port';
|
||||||
|
import { Settings } from './settings';
|
||||||
|
import { Video } from './video';
|
||||||
|
|
||||||
|
export const Menu = () => {
|
||||||
|
const serialState = useAtomValue(serialStateAtom);
|
||||||
|
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isOpen = storage.getIsMenuOpen();
|
||||||
|
setIsMenuOpen(isOpen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
const isOpen = !isMenuOpen;
|
||||||
|
|
||||||
|
setIsMenuOpen(isOpen);
|
||||||
|
storage.setIsMenuOpen(isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2">
|
||||||
|
<div className="sticky top-[10px]">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'h-[34px] items-center justify-between space-x-1.5 rounded bg-neutral-800/70 px-3',
|
||||||
|
isMenuOpen ? 'flex' : 'hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Video />
|
||||||
|
|
||||||
|
{serialState === 'connected' && (
|
||||||
|
<>
|
||||||
|
<SerialPort />
|
||||||
|
|
||||||
|
<Divider type="vertical" />
|
||||||
|
|
||||||
|
<Keyboard />
|
||||||
|
<Mouse />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider type="vertical" />
|
||||||
|
|
||||||
|
<Settings />
|
||||||
|
<Fullscreen />
|
||||||
|
<div
|
||||||
|
className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70"
|
||||||
|
onClick={toggleMenu}
|
||||||
|
>
|
||||||
|
<XIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] w-[35px] cursor-pointer items-center justify-center rounded bg-neutral-800/50 text-white/50 hover:bg-neutral-800 hover:text-white"
|
||||||
|
onClick={toggleMenu}
|
||||||
|
>
|
||||||
|
<MenuIcon size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
browser/src/components/menu/keyboard/index.tsx
Normal file
32
browser/src/components/menu/keyboard/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Popover } from 'antd';
|
||||||
|
import { KeyboardIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Paste } from './paste.tsx';
|
||||||
|
import { VirtualKeyboard } from './virtual-keyboard.tsx';
|
||||||
|
|
||||||
|
export const Keyboard = () => {
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<Paste />
|
||||||
|
<VirtualKeyboard />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
arrow={false}
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
>
|
||||||
|
<div className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70">
|
||||||
|
<KeyboardIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
57
browser/src/components/menu/keyboard/paste.tsx
Normal file
57
browser/src/components/menu/keyboard/paste.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ClipboardIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { device } from '@/libs/device';
|
||||||
|
import { Modifiers } from '@/libs/device/keyboard.ts';
|
||||||
|
import { CharCodes, ShiftChars } from '@/libs/keyboard';
|
||||||
|
|
||||||
|
export const Paste = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
async function paste() {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
for (const char of text) {
|
||||||
|
const ascii = char.charCodeAt(0);
|
||||||
|
|
||||||
|
const code = CharCodes.get(ascii);
|
||||||
|
if (!code) continue;
|
||||||
|
|
||||||
|
const modifiers = new Modifiers();
|
||||||
|
if ((ascii >= 65 && ascii <= 90) || ShiftChars.has(ascii)) {
|
||||||
|
modifiers.leftShift = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await send(modifiers, code);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(modifiers: Modifiers, code: number) {
|
||||||
|
const keys = [0x00, 0x00, code, 0x00, 0x00, 0x00];
|
||||||
|
await device.sendKeyboardData(modifiers, keys);
|
||||||
|
|
||||||
|
await device.sendKeyboardData(new Modifiers(), [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50"
|
||||||
|
onClick={paste}
|
||||||
|
>
|
||||||
|
<ClipboardIcon size={18} />
|
||||||
|
<span>{t('keyboard.paste')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
browser/src/components/menu/keyboard/virtual-keyboard.tsx
Normal file
20
browser/src/components/menu/keyboard/virtual-keyboard.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { KeyboardIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
|
||||||
|
|
||||||
|
export const VirtualKeyboard = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsKeyboardOpen = useSetAtom(isKeyboardOpenAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50"
|
||||||
|
onClick={() => setIsKeyboardOpen(true)}
|
||||||
|
>
|
||||||
|
<KeyboardIcon size={18} />
|
||||||
|
<span>{t('keyboard.virtualKeyboard')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
58
browser/src/components/menu/mouse/direction.tsx
Normal file
58
browser/src/components/menu/mouse/direction.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { ReactElement } from 'react';
|
||||||
|
import { Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { ArrowDownUpIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { scrollDirectionAtom } from '@/jotai/mouse.ts';
|
||||||
|
import * as storage from '@/libs/storage';
|
||||||
|
|
||||||
|
export const Direction = (): ReactElement => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [scrollDirection, setScrollDirection] = useAtom(scrollDirectionAtom);
|
||||||
|
|
||||||
|
const directions = [
|
||||||
|
{ name: t('mouse.scrollUp'), value: '1' },
|
||||||
|
{ name: t('mouse.scrollDown'), value: '-1' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function update(direction: string): void {
|
||||||
|
setScrollDirection(Number(direction));
|
||||||
|
storage.setMouseScrollDirection(direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
{directions.map((direction) => (
|
||||||
|
<div
|
||||||
|
key={direction.value}
|
||||||
|
className={clsx(
|
||||||
|
'my-1 flex cursor-pointer items-center space-x-1 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/50',
|
||||||
|
direction.value === scrollDirection.toString()
|
||||||
|
? 'text-blue-500'
|
||||||
|
: 'text-neutral-300'
|
||||||
|
)}
|
||||||
|
onClick={() => update(direction.value)}
|
||||||
|
>
|
||||||
|
{direction.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
placement="rightTop"
|
||||||
|
arrow={true}
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
<ArrowDownUpIcon size={14} />
|
||||||
|
</div>
|
||||||
|
<span>{t('mouse.direction')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
59
browser/src/components/menu/mouse/index.tsx
Normal file
59
browser/src/components/menu/mouse/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Popover } from 'antd';
|
||||||
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
|
import { MouseIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { mouseModeAtom, mouseStyleAtom, scrollDirectionAtom } from '@/jotai/mouse.ts';
|
||||||
|
import * as storage from '@/libs/storage';
|
||||||
|
|
||||||
|
import { Direction } from './direction.tsx';
|
||||||
|
import { Mode } from './mode.tsx';
|
||||||
|
import { Style } from './style.tsx';
|
||||||
|
|
||||||
|
export const Mouse = () => {
|
||||||
|
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
|
||||||
|
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
|
||||||
|
const setScrollDirection = useSetAtom(scrollDirectionAtom);
|
||||||
|
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const style = storage.getMouseStyle();
|
||||||
|
if (style && style !== mouseStyle) {
|
||||||
|
setMouseStyle(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = storage.getMouseMode();
|
||||||
|
if (mode && mode !== mouseMode) {
|
||||||
|
setMouseMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = storage.getMouseScrollDirection();
|
||||||
|
if (direction && Number(direction)) {
|
||||||
|
setScrollDirection(Number(direction));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<Style />
|
||||||
|
<Mode />
|
||||||
|
<Direction />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger="click"
|
||||||
|
arrow={false}
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
>
|
||||||
|
<div className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70">
|
||||||
|
<MouseIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
54
browser/src/components/menu/mouse/mode.tsx
Normal file
54
browser/src/components/menu/mouse/mode.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { SquareMousePointerIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { mouseModeAtom } from '@/jotai/mouse.ts';
|
||||||
|
import * as storage from '@/libs/storage';
|
||||||
|
|
||||||
|
export const Mode = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
|
||||||
|
|
||||||
|
const mouseModes = [
|
||||||
|
{ name: t('mouse.absolute'), value: 'absolute' },
|
||||||
|
{ name: t('mouse.relative'), value: 'relative' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function update(mode: string) {
|
||||||
|
setMouseMode(mode);
|
||||||
|
storage.setMouseMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
{mouseModes.map((mode) => (
|
||||||
|
<div
|
||||||
|
key={mode.value}
|
||||||
|
className={clsx(
|
||||||
|
'my-1 flex cursor-pointer items-center space-x-1 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/50',
|
||||||
|
mode.value === mouseMode ? 'text-blue-500' : 'text-neutral-300'
|
||||||
|
)}
|
||||||
|
onClick={() => update(mode.value)}
|
||||||
|
>
|
||||||
|
{mode.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
placement="rightTop"
|
||||||
|
arrow={true}
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">
|
||||||
|
<SquareMousePointerIcon size={14} />
|
||||||
|
</div>
|
||||||
|
<span>{t('mouse.mode')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
57
browser/src/components/menu/mouse/style.tsx
Normal file
57
browser/src/components/menu/mouse/style.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { EyeOffIcon, HandIcon, MousePointerIcon, PlusIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||||
|
import * as storage from '@/libs/storage';
|
||||||
|
|
||||||
|
export const Style = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
|
||||||
|
|
||||||
|
function updateStyle(style: string) {
|
||||||
|
setMouseStyle(style);
|
||||||
|
storage.setMouseStyle(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mouseStyles = [
|
||||||
|
{
|
||||||
|
name: t('mouse.cursor.pointer'),
|
||||||
|
icon: <MousePointerIcon size={14} />,
|
||||||
|
value: 'cursor-default'
|
||||||
|
},
|
||||||
|
{ name: t('mouse.cursor.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
|
||||||
|
{ name: t('mouse.cursor.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
|
||||||
|
{ name: t('mouse.cursor.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{mouseStyles.map((style) => (
|
||||||
|
<div
|
||||||
|
key={style.value}
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-3 pr-5 hover:bg-neutral-700/50',
|
||||||
|
style.value === mouseStyle ? 'text-blue-500' : 'text-neutral-300'
|
||||||
|
)}
|
||||||
|
onClick={() => updateStyle(style.value)}
|
||||||
|
>
|
||||||
|
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
|
||||||
|
<span>{style.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="rightTop">
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<MousePointerIcon size={18} />
|
||||||
|
<span className="select-none text-sm">{t('mouse.cursor.title')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
browser/src/components/menu/serial-port/index.tsx
Normal file
28
browser/src/components/menu/serial-port/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { CpuIcon, Loader2Icon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { device } from '@/libs/device';
|
||||||
|
|
||||||
|
export const SerialPort = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
async function selectSerial() {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const port = await navigator.serial.requestPort();
|
||||||
|
await device.serialPort.init(port);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center text-neutral-300" onClick={selectSerial}>
|
||||||
|
<div className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70">
|
||||||
|
{isLoading ? <Loader2Icon className="animate-spin" size={18} /> : <CpuIcon size={18} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
browser/src/components/menu/settings/index.tsx
Normal file
43
browser/src/components/menu/settings/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import { BookIcon, DownloadIcon, SettingsIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Language } from './language.tsx';
|
||||||
|
|
||||||
|
export const Settings = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function openPage(url: string) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<Language />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700"
|
||||||
|
onClick={() => openPage('https://wiki.sipeed.com/nanokvmusb')}
|
||||||
|
>
|
||||||
|
<BookIcon size={18} />
|
||||||
|
<span>{t('settings.document')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700"
|
||||||
|
onClick={() => openPage('https://github.com/sipeed/NanoKVM-USB/releases')}
|
||||||
|
>
|
||||||
|
<DownloadIcon size={18} />
|
||||||
|
<span>{t('settings.download')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="bottomLeft" trigger="click" arrow={false}>
|
||||||
|
<div className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70">
|
||||||
|
<SettingsIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
browser/src/components/menu/settings/language.tsx
Normal file
44
browser/src/components/menu/settings/language.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { LanguagesIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import languages from '@/i18n/languages.ts';
|
||||||
|
import { setLanguage } from '@/libs/storage';
|
||||||
|
|
||||||
|
export const Language = () => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
function changeLanguage(lng: string) {
|
||||||
|
if (i18n.language === lng) return;
|
||||||
|
|
||||||
|
i18n.changeLanguage(lng);
|
||||||
|
setLanguage(lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{languages.map((lng) => (
|
||||||
|
<div
|
||||||
|
key={lng.key}
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
|
||||||
|
i18n.language === lng.key ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
|
||||||
|
)}
|
||||||
|
onClick={() => changeLanguage(lng.key)}
|
||||||
|
>
|
||||||
|
{lng.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="rightTop">
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<LanguagesIcon size={18} />
|
||||||
|
<span>{t('settings.language')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
72
browser/src/components/menu/video/device.tsx
Normal file
72
browser/src/components/menu/video/device.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
|
import { VideoIcon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { resolutionAtom, videoDeviceIdAtom } from '@/jotai/device.ts';
|
||||||
|
import { camera } from '@/libs/camera';
|
||||||
|
import * as storage from '@/libs/storage';
|
||||||
|
|
||||||
|
export const Device = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const resolution = useAtomValue(resolutionAtom);
|
||||||
|
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom);
|
||||||
|
|
||||||
|
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigator.mediaDevices.enumerateDevices().then((deviceInfo) => {
|
||||||
|
const videoDevices = deviceInfo.filter((device) => device.kind === 'videoinput');
|
||||||
|
setDevices(videoDevices);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function selectDevice(deviceId: string) {
|
||||||
|
if (isLoading) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await camera.open(deviceId, resolution.width, resolution.height);
|
||||||
|
if (!success) return;
|
||||||
|
|
||||||
|
const video = document.getElementById('video') as HTMLVideoElement;
|
||||||
|
if (!video) return;
|
||||||
|
video.srcObject = camera.getStream();
|
||||||
|
|
||||||
|
setVideoDeviceId(deviceId);
|
||||||
|
storage.setVideoDevice(deviceId);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{devices.map((device: MediaDeviceInfo) => (
|
||||||
|
<div
|
||||||
|
key={device.deviceId}
|
||||||
|
className={clsx(
|
||||||
|
'cursor-pointer rounded px-2 py-1.5 hover:bg-neutral-700/60',
|
||||||
|
device.deviceId === videoDeviceId ? 'text-blue-500' : 'text-white'
|
||||||
|
)}
|
||||||
|
onClick={() => selectDevice(device.deviceId)}
|
||||||
|
>
|
||||||
|
{device.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="rightTop">
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<VideoIcon size={18} />
|
||||||
|
<span className="select-none text-sm">{t('video.device')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
22
browser/src/components/menu/video/index.tsx
Normal file
22
browser/src/components/menu/video/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Popover } from 'antd';
|
||||||
|
import { MonitorIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Device } from './device.tsx';
|
||||||
|
import { Resolution } from './resolution.tsx';
|
||||||
|
|
||||||
|
export const Video = () => {
|
||||||
|
const content = (
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<Resolution />
|
||||||
|
<Device />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={content} placement="bottomLeft" trigger="click" arrow={false}>
|
||||||
|
<div className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70">
|
||||||
|
<MonitorIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
196
browser/src/components/menu/video/resolution.tsx
Normal file
196
browser/src/components/menu/video/resolution.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Divider, InputNumber, Modal, Popover } from 'antd';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
|
import { RatioIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { resolutionAtom } from '@/jotai/device.ts';
|
||||||
|
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||||
|
import { camera } from '@/libs/camera';
|
||||||
|
import * as storage from '@/libs/storage';
|
||||||
|
import type { Resolution as VideoResolution } from '@/types';
|
||||||
|
|
||||||
|
export const Resolution = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||||
|
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
const [height, setHeight] = useState(0);
|
||||||
|
const [customResolutions, setCustomResolutions] = useState<VideoResolution[]>([]);
|
||||||
|
|
||||||
|
const resolutions: VideoResolution[] = [
|
||||||
|
{ width: 2560, height: 1440 },
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
{ width: 1280, height: 720 },
|
||||||
|
{ width: 800, height: 600 },
|
||||||
|
{ width: 640, height: 480 }
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resolutions = storage.getCustomResolutions();
|
||||||
|
if (resolutions) {
|
||||||
|
setCustomResolutions(resolutions);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsKeyboardEnable(!isOpen);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
function showModal() {
|
||||||
|
setWidth(0);
|
||||||
|
setHeight(0);
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!width || !height || (width === resolution.width && height === resolution.height)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isExist = resolutions.some((r) => r.width === width && r.height === height);
|
||||||
|
if (isExist) return;
|
||||||
|
isExist = customResolutions.some((r) => r.width === width && r.height === height);
|
||||||
|
if (isExist) return;
|
||||||
|
|
||||||
|
setCustomResolutions([...customResolutions, { width, height }]);
|
||||||
|
storage.setCustomResolution(width, height);
|
||||||
|
|
||||||
|
updateResolution(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateResolution(w: number, h: number) {
|
||||||
|
const success = await camera.open('', w, h);
|
||||||
|
if (!success) return;
|
||||||
|
|
||||||
|
const video = document.getElementById('video') as HTMLVideoElement;
|
||||||
|
if (!video) return;
|
||||||
|
video.srcObject = camera.getStream();
|
||||||
|
|
||||||
|
setResolution({ width: w, height: h });
|
||||||
|
storage.setVideoResolution(w, h);
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCustomResolution(e: any) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const isExist = customResolutions.some(
|
||||||
|
(r) => r.width === resolution.width && r.height === resolution.height
|
||||||
|
);
|
||||||
|
if (isExist) {
|
||||||
|
updateResolution(1920, 1080);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCustomResolutions([]);
|
||||||
|
storage.removeCustomResolutions();
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{/* resolution list */}
|
||||||
|
{resolutions.map((res) => (
|
||||||
|
<div
|
||||||
|
key={res.width}
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-700/60',
|
||||||
|
resolution.width === res.width && resolution.height === res.height
|
||||||
|
? 'text-blue-500'
|
||||||
|
: 'text-white'
|
||||||
|
)}
|
||||||
|
onClick={() => updateResolution(res.width, res.height)}
|
||||||
|
>
|
||||||
|
<span className="flex w-[32px]">{res.width}</span>
|
||||||
|
<span>x</span>
|
||||||
|
<span className="w-[32px]">{res.height}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '5px 0 5px 0' }} />
|
||||||
|
|
||||||
|
{/* custom resolution */}
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer select-none items-center justify-between space-x-3 rounded px-3 py-1.5 text-sm hover:bg-neutral-700/60"
|
||||||
|
onClick={showModal}
|
||||||
|
>
|
||||||
|
<span>{t('video.customResolution')}</span>
|
||||||
|
{customResolutions.length > 0 && (
|
||||||
|
<span className="hover:text-red-500" onClick={removeCustomResolution}>
|
||||||
|
<Trash2Icon size={16} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customResolutions.map((res) => (
|
||||||
|
<div
|
||||||
|
key={res.width}
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-700/60',
|
||||||
|
resolution.width === res.width && resolution.height === res.height
|
||||||
|
? 'text-blue-500'
|
||||||
|
: 'text-white'
|
||||||
|
)}
|
||||||
|
onClick={() => updateResolution(res.width, res.height)}
|
||||||
|
>
|
||||||
|
<span className="flex w-[32px]">{res.width}</span>
|
||||||
|
<span>x</span>
|
||||||
|
<span className="w-[32px]">{res.height}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Popover content={content} placement="rightTop">
|
||||||
|
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||||
|
<RatioIcon size={18} />
|
||||||
|
<span className="select-none text-sm">{t('video.resolution')}</span>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={isOpen}
|
||||||
|
title={t('video.custom.title')}
|
||||||
|
footer={null}
|
||||||
|
closable={false}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-5 py-10">
|
||||||
|
<div className="flex items-center space-x-5">
|
||||||
|
<span className="text-sm">{t('video.custom.width')}</span>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
controls={false}
|
||||||
|
defaultValue={resolution.width}
|
||||||
|
onChange={(value) => setWidth(value || 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-5">
|
||||||
|
<span className="text-sm">{t('video.custom.height')}</span>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
controls={false}
|
||||||
|
defaultValue={resolution.height}
|
||||||
|
onChange={(value) => setHeight(value || 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-5">
|
||||||
|
<Button type="primary" className="w-20" onClick={submit}>
|
||||||
|
{t('video.custom.confirm')}
|
||||||
|
</Button>
|
||||||
|
<Button type="default" className="w-20" onClick={() => setIsOpen(false)}>
|
||||||
|
{t('video.custom.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
112
browser/src/components/mouse/absolute.tsx
Normal file
112
browser/src/components/mouse/absolute.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
||||||
|
import { resolutionAtom } from '@/jotai/device.ts';
|
||||||
|
import { scrollDirectionAtom } from '@/jotai/mouse.ts';
|
||||||
|
import { device } from '@/libs/device';
|
||||||
|
import { Key } from '@/libs/device/mouse.ts';
|
||||||
|
|
||||||
|
export const Absolute = () => {
|
||||||
|
const resolution = useAtomValue(resolutionAtom);
|
||||||
|
const scrollDirection = useAtomValue(scrollDirectionAtom);
|
||||||
|
|
||||||
|
const keyRef = useRef<Key>(new Key());
|
||||||
|
|
||||||
|
// listen mouse events
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = document.getElementById('video');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', handleMouseDown);
|
||||||
|
canvas.addEventListener('mouseup', handleMouseUp);
|
||||||
|
canvas.addEventListener('mousemove', handleMouseMove);
|
||||||
|
canvas.addEventListener('wheel', handleWheel);
|
||||||
|
canvas.addEventListener('click', disableEvent);
|
||||||
|
canvas.addEventListener('contextmenu', disableEvent);
|
||||||
|
|
||||||
|
// press button
|
||||||
|
async function handleMouseDown(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
switch (event.button) {
|
||||||
|
case 0:
|
||||||
|
keyRef.current.left = true;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
keyRef.current.mid = true;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
keyRef.current.right = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`unknown button ${event.button}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// release button
|
||||||
|
async function handleMouseUp(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
switch (event.button) {
|
||||||
|
case 0:
|
||||||
|
keyRef.current.left = false;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
keyRef.current.mid = false;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
keyRef.current.right = false;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`unknown button ${event.button}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouse move
|
||||||
|
async function handleMouseMove(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
await send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouse scroll
|
||||||
|
async function handleWheel(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
const delta = Math.floor(event.deltaY);
|
||||||
|
if (delta === 0) return;
|
||||||
|
|
||||||
|
await send(event, delta > 0 ? -1 * scrollDirection : scrollDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(event: MouseEvent, scroll: number = 0) {
|
||||||
|
const rect = canvas!.getBoundingClientRect();
|
||||||
|
const x = Math.abs(event.clientX - rect.left);
|
||||||
|
const y = Math.abs(event.clientY - rect.top);
|
||||||
|
|
||||||
|
await device.sendMouseAbsoluteData(keyRef.current, rect.width, rect.height, x, y, scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
canvas.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
canvas.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
canvas.removeEventListener('wheel', handleWheel);
|
||||||
|
canvas.removeEventListener('click', disableEvent);
|
||||||
|
canvas.removeEventListener('contextmenu', disableEvent);
|
||||||
|
};
|
||||||
|
}, [resolution, scrollDirection]);
|
||||||
|
|
||||||
|
// disable default events
|
||||||
|
function disableEvent(event: any) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
12
browser/src/components/mouse/index.tsx
Normal file
12
browser/src/components/mouse/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
||||||
|
import { mouseModeAtom } from '@/jotai/mouse.ts';
|
||||||
|
|
||||||
|
import { Absolute } from './absolute.tsx';
|
||||||
|
import { Relative } from './relative.tsx';
|
||||||
|
|
||||||
|
export const Mouse = () => {
|
||||||
|
const mouseMode = useAtomValue(mouseModeAtom);
|
||||||
|
|
||||||
|
return <>{mouseMode === 'relative' ? <Relative /> : <Absolute />}</>;
|
||||||
|
};
|
||||||
145
browser/src/components/mouse/relative.tsx
Normal file
145
browser/src/components/mouse/relative.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { resolutionAtom } from '@/jotai/device.ts';
|
||||||
|
import { scrollDirectionAtom } from '@/jotai/mouse.ts';
|
||||||
|
import { device } from '@/libs/device';
|
||||||
|
import { Key } from '@/libs/device/mouse.ts';
|
||||||
|
|
||||||
|
export const Relative = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
const resolution = useAtomValue(resolutionAtom);
|
||||||
|
const scrollDirection = useAtomValue(scrollDirectionAtom);
|
||||||
|
|
||||||
|
const isLockedRef = useRef(false);
|
||||||
|
const keyRef = useRef<Key>(new Key());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messageApi.open({
|
||||||
|
key: 'relative',
|
||||||
|
type: 'info',
|
||||||
|
content: t('mouse.requestPointer'),
|
||||||
|
duration: 3,
|
||||||
|
style: {
|
||||||
|
marginTop: '40vh'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// listen mouse events
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = document.getElementById('video');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
document.addEventListener('pointerlockchange', handlePointerLockChange);
|
||||||
|
canvas.addEventListener('click', handleClick);
|
||||||
|
canvas.addEventListener('mousedown', handleMouseDown);
|
||||||
|
canvas.addEventListener('mouseup', handleMouseUp);
|
||||||
|
canvas.addEventListener('mousemove', handleMouseMove);
|
||||||
|
canvas.addEventListener('wheel', handleWheel);
|
||||||
|
canvas.addEventListener('contextmenu', disableEvent);
|
||||||
|
|
||||||
|
function handleClick(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
if (!isLockedRef.current) {
|
||||||
|
canvas!.requestPointerLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerLockChange() {
|
||||||
|
isLockedRef.current = document.pointerLockElement === canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// press button
|
||||||
|
async function handleMouseDown(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
switch (event.button) {
|
||||||
|
case 0:
|
||||||
|
keyRef.current.left = true;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
keyRef.current.mid = true;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
keyRef.current.right = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`unknown button ${event.button}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await send(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// release button
|
||||||
|
async function handleMouseUp(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
switch (event.button) {
|
||||||
|
case 0:
|
||||||
|
keyRef.current.left = false;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
keyRef.current.mid = false;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
keyRef.current.right = false;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`unknown button ${event.button}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await send(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouse move
|
||||||
|
async function handleMouseMove(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
const x = event.movementX || event.mozMovementX || event.webkitMovementX || 0;
|
||||||
|
const y = event.movementY || event.mozMovementY || event.webkitMovementY || 0;
|
||||||
|
if (x === 0 && y === 0) return;
|
||||||
|
|
||||||
|
await send(Math.abs(x) < 10 ? x * 2 : x, Math.abs(y) < 10 ? y * 2 : y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouse scroll
|
||||||
|
async function handleWheel(event: any) {
|
||||||
|
disableEvent(event);
|
||||||
|
|
||||||
|
const delta = Math.floor(event.deltaY);
|
||||||
|
if (delta === 0) return;
|
||||||
|
|
||||||
|
await send(0, 0, delta > 0 ? -1 * scrollDirection : scrollDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerlockchange', handlePointerLockChange);
|
||||||
|
canvas.removeEventListener('click', handleClick);
|
||||||
|
canvas.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
canvas.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
canvas.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
canvas.removeEventListener('wheel', handleWheel);
|
||||||
|
canvas.removeEventListener('contextmenu', disableEvent);
|
||||||
|
};
|
||||||
|
}, [resolution, scrollDirection]);
|
||||||
|
|
||||||
|
async function send(x: number, y: number, scroll: number) {
|
||||||
|
await device.sendMouseRelativeData(keyRef.current, x, y, scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// disable default events
|
||||||
|
function disableEvent(event: any) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{contextHolder}</>;
|
||||||
|
};
|
||||||
182
browser/src/components/virtual-keyboard/index.tsx
Normal file
182
browser/src/components/virtual-keyboard/index.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { XIcon } from 'lucide-react';
|
||||||
|
import Keyboard, { KeyboardButtonTheme } from 'react-simple-keyboard';
|
||||||
|
import { Drawer } from 'vaul';
|
||||||
|
|
||||||
|
import 'react-simple-keyboard/build/css/index.css';
|
||||||
|
import '@/assets/keyboard.css';
|
||||||
|
|
||||||
|
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
|
||||||
|
import { device } from '@/libs/device';
|
||||||
|
import { Modifiers } from '@/libs/device/keyboard.ts';
|
||||||
|
import { KeyboardCodes } from '@/libs/keyboard';
|
||||||
|
|
||||||
|
import {
|
||||||
|
doubleKeys,
|
||||||
|
keyboardArrowsOptions,
|
||||||
|
keyboardControlPadOptions,
|
||||||
|
keyboardOptions,
|
||||||
|
modifierKeys,
|
||||||
|
specialKeyMap
|
||||||
|
} from './keys.ts';
|
||||||
|
|
||||||
|
type KeyboardProps = {
|
||||||
|
isBigScreen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VirtualKeyboard = ({ isBigScreen }: KeyboardProps) => {
|
||||||
|
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
|
||||||
|
|
||||||
|
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const keyboardRef = useRef<any>(null);
|
||||||
|
|
||||||
|
async function onKeyPress(key: string) {
|
||||||
|
if (modifierKeys.includes(key)) {
|
||||||
|
if (activeModifierKeys.includes(key)) {
|
||||||
|
await sendKeydown(key);
|
||||||
|
await sendKeyup();
|
||||||
|
} else {
|
||||||
|
setActiveModifierKeys([...activeModifierKeys, key]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendKeydown(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onKeyReleased(key: string) {
|
||||||
|
if (modifierKeys.includes(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendKeyup();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendKeydown(key: string) {
|
||||||
|
const specialKey = specialKeyMap.get(key);
|
||||||
|
const code = KeyboardCodes.get(specialKey ? specialKey : key);
|
||||||
|
if (!code) {
|
||||||
|
console.log('unknown code: ', key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctrl = getCtrl();
|
||||||
|
const keys = [0x00, 0x00, code, 0x00, 0x00, 0x00];
|
||||||
|
await device.sendKeyboardData(ctrl, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendKeyup() {
|
||||||
|
const ctrl = new Modifiers();
|
||||||
|
const keys = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||||
|
await device.sendKeyboardData(ctrl, keys);
|
||||||
|
|
||||||
|
setActiveModifierKeys([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCtrl() {
|
||||||
|
const modifiers = new Modifiers();
|
||||||
|
|
||||||
|
activeModifierKeys.forEach((modifierKey) => {
|
||||||
|
const specialKey = specialKeyMap.get(modifierKey)!;
|
||||||
|
switch (specialKey) {
|
||||||
|
case 'ControlLeft':
|
||||||
|
modifiers.leftCtrl = true;
|
||||||
|
break;
|
||||||
|
case 'ControlRight':
|
||||||
|
modifiers.rightCtrl = true;
|
||||||
|
break;
|
||||||
|
case 'ShiftLeft':
|
||||||
|
modifiers.leftShift = true;
|
||||||
|
break;
|
||||||
|
case 'ShiftRight':
|
||||||
|
modifiers.rightShift = true;
|
||||||
|
break;
|
||||||
|
case 'AltLeft':
|
||||||
|
modifiers.leftAlt = true;
|
||||||
|
break;
|
||||||
|
case 'AltRight':
|
||||||
|
modifiers.rightAlt = true;
|
||||||
|
break;
|
||||||
|
case 'MetaLeft':
|
||||||
|
modifiers.leftWindows = true;
|
||||||
|
break;
|
||||||
|
case 'MetaRight':
|
||||||
|
modifiers.rightWindows = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getButtonTheme(): KeyboardButtonTheme[] {
|
||||||
|
const theme = [{ class: 'hg-double', buttons: doubleKeys.join(' ') }];
|
||||||
|
|
||||||
|
if (activeModifierKeys.length > 0) {
|
||||||
|
const buttons = activeModifierKeys.join(' ');
|
||||||
|
theme.push({ class: 'hg-highlight', buttons });
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer.Root open={isKeyboardOpen} onOpenChange={setIsKeyboardOpen} modal={false}>
|
||||||
|
<Drawer.Portal>
|
||||||
|
<Drawer.Content
|
||||||
|
className={clsx(
|
||||||
|
'fixed bottom-0 left-0 right-0 z-[999] mx-auto overflow-hidden rounded bg-white outline-none',
|
||||||
|
isBigScreen ? 'w-[820px]' : 'w-[650px]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex justify-end px-3 py-1">
|
||||||
|
<div
|
||||||
|
className="flex h-[20px] w-[20px] cursor-pointer items-center justify-center rounded text-neutral-600 hover:bg-neutral-300 hover:text-white"
|
||||||
|
onClick={() => setIsKeyboardOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px flex-shrink-0 border-b bg-neutral-300" />
|
||||||
|
|
||||||
|
<div data-vaul-no-drag className="keyboardContainer w-full">
|
||||||
|
{/* main keyboard */}
|
||||||
|
<Keyboard
|
||||||
|
buttonTheme={getButtonTheme()}
|
||||||
|
keyboardRef={(r: any) => (keyboardRef.current = r)}
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
onKeyReleased={onKeyReleased}
|
||||||
|
layoutName="default"
|
||||||
|
{...keyboardOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* control keyboard */}
|
||||||
|
{isBigScreen && (
|
||||||
|
<div className="controlArrows">
|
||||||
|
<Keyboard
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
onKeyReleased={onKeyReleased}
|
||||||
|
{...keyboardControlPadOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Keyboard
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
onKeyReleased={onKeyReleased}
|
||||||
|
{...keyboardArrowsOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Drawer.Content>
|
||||||
|
<Drawer.Overlay />
|
||||||
|
</Drawer.Portal>
|
||||||
|
</Drawer.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
199
browser/src/components/virtual-keyboard/keys.ts
Normal file
199
browser/src/components/virtual-keyboard/keys.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// main keys
|
||||||
|
export const keyboardOptions = {
|
||||||
|
theme: 'simple-keyboard hg-theme-default',
|
||||||
|
baseClass: 'simple-keyboard-main',
|
||||||
|
layout: {
|
||||||
|
default: [
|
||||||
|
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||||
|
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal {backspace}',
|
||||||
|
'{tab} KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
|
||||||
|
'{capslock} KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote {enter}',
|
||||||
|
'{shiftleft} KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash {shiftright}',
|
||||||
|
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||||
|
],
|
||||||
|
mac: [
|
||||||
|
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||||
|
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal {backspace}',
|
||||||
|
'{tab} KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
|
||||||
|
'{capslock} KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote {enter}',
|
||||||
|
'{shiftleft} KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash {shiftright}',
|
||||||
|
'{controlleft} {altleft} {metaleft} {space} {metaright} {altright}'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
'{escape}': 'Esc',
|
||||||
|
Backquote: '~<br/>`',
|
||||||
|
Digit1: '!<br/>1',
|
||||||
|
Digit2: '@<br/>2',
|
||||||
|
Digit3: '#<br/>3',
|
||||||
|
Digit4: '$<br/>4',
|
||||||
|
Digit5: '%<br/>5',
|
||||||
|
Digit6: '^<br/>6',
|
||||||
|
Digit7: '&<br/>7',
|
||||||
|
Digit8: '*<br/>8',
|
||||||
|
Digit9: '(<br/>9',
|
||||||
|
Digit0: ')<br/>0',
|
||||||
|
Minus: '_<br/>-',
|
||||||
|
Equal: '+<br/>=',
|
||||||
|
'{backspace}': 'Backspace',
|
||||||
|
|
||||||
|
'{tab}': 'Tab',
|
||||||
|
KeyQ: 'Q',
|
||||||
|
KeyW: 'W',
|
||||||
|
KeyE: 'E',
|
||||||
|
KeyR: 'R',
|
||||||
|
KeyT: 'T',
|
||||||
|
KeyY: 'Y',
|
||||||
|
KeyU: 'U',
|
||||||
|
KeyI: 'I',
|
||||||
|
KeyO: 'O',
|
||||||
|
KeyP: 'P',
|
||||||
|
BracketLeft: '{<br/>[',
|
||||||
|
BracketRight: '}<br/>]',
|
||||||
|
Backslash: '|<br>\\',
|
||||||
|
|
||||||
|
'{capslock}': 'Caps',
|
||||||
|
KeyA: 'A',
|
||||||
|
KeyS: 'S',
|
||||||
|
KeyD: 'D',
|
||||||
|
KeyF: 'F',
|
||||||
|
KeyG: 'G',
|
||||||
|
KeyH: 'H',
|
||||||
|
KeyJ: 'J',
|
||||||
|
KeyK: 'K',
|
||||||
|
KeyL: 'L',
|
||||||
|
Semicolon: ':<br/>;',
|
||||||
|
Quote: '"<br/>\'',
|
||||||
|
'{enter}': 'Enter',
|
||||||
|
|
||||||
|
'{shiftleft}': 'Shift',
|
||||||
|
KeyZ: 'Z',
|
||||||
|
KeyX: 'X',
|
||||||
|
KeyC: 'C',
|
||||||
|
KeyV: 'V',
|
||||||
|
KeyB: 'B',
|
||||||
|
KeyN: 'N',
|
||||||
|
KeyM: 'M',
|
||||||
|
Comma: '<<br/>,',
|
||||||
|
Period: '><br/>.',
|
||||||
|
Slash: '?<br/>/',
|
||||||
|
'{shiftright}': 'Shift',
|
||||||
|
|
||||||
|
'{controlleft}': 'Ctrl',
|
||||||
|
'{altleft}': 'Alt',
|
||||||
|
'{metaleft}': 'Cmd',
|
||||||
|
'{winleft}': 'Win',
|
||||||
|
'{space}': 'Space',
|
||||||
|
'{metaright}': 'Cmd',
|
||||||
|
'{winright}': 'Win',
|
||||||
|
'{altright}': 'Alt',
|
||||||
|
'{menu}': 'Menu',
|
||||||
|
'{controlright}': 'Ctrl'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// control keys
|
||||||
|
export const keyboardControlPadOptions = {
|
||||||
|
theme: 'simple-keyboard hg-theme-default',
|
||||||
|
baseClass: 'simple-keyboard-control',
|
||||||
|
layout: {
|
||||||
|
default: [
|
||||||
|
'{prtscr} {scrolllock} {pause}',
|
||||||
|
'{insert} {home} {pageup}',
|
||||||
|
'{delete} {end} {pagedown}'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
display: {
|
||||||
|
'{prtscr}': 'PrtScr',
|
||||||
|
'{scrolllock}': 'Lock',
|
||||||
|
'{pause}': 'Pause',
|
||||||
|
'{insert}': 'Ins',
|
||||||
|
'{home}': 'Home',
|
||||||
|
'{pageup}': 'PgUp',
|
||||||
|
'{delete}': 'Del',
|
||||||
|
'{end}': 'End',
|
||||||
|
'{pagedown}': 'PgDn'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// arrow keys
|
||||||
|
export const keyboardArrowsOptions = {
|
||||||
|
theme: 'simple-keyboard hg-theme-default',
|
||||||
|
baseClass: 'simple-keyboard-arrows',
|
||||||
|
layout: {
|
||||||
|
default: ['{arrowup}', '{arrowleft} {arrowdown} {arrowright}']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// keys require special mapping
|
||||||
|
export const specialKeyMap = new Map([
|
||||||
|
['{escape}', 'Escape'],
|
||||||
|
['{backspace}', 'Backspace'],
|
||||||
|
['{tab}', 'Tab'],
|
||||||
|
['{capslock}', 'CapsLock'],
|
||||||
|
['{enter}', 'Enter'],
|
||||||
|
['{shiftleft}', 'ShiftLeft'],
|
||||||
|
['{shiftright}', 'ShiftRight'],
|
||||||
|
['{controlleft}', 'ControlLeft'],
|
||||||
|
['{controlright}', 'ControlRight'],
|
||||||
|
['{altleft}', 'AltLeft'],
|
||||||
|
['{metaleft}', 'MetaLeft'],
|
||||||
|
['{winleft}', 'WinLeft'],
|
||||||
|
['{space}', 'Space'],
|
||||||
|
['{metaright}', 'MetaRight'],
|
||||||
|
['{winright}', 'WinRight'],
|
||||||
|
['{altright}', 'AltRight'],
|
||||||
|
['{prtscr}', 'PrintScreen'],
|
||||||
|
['{scrolllock}', 'ScrollLock'],
|
||||||
|
['{pause}', 'Pause'],
|
||||||
|
['{insert}', 'Insert'],
|
||||||
|
['{home}', 'Home'],
|
||||||
|
['{pageup}', 'PageUp'],
|
||||||
|
['{delete}', 'Delete'],
|
||||||
|
['{end}', 'End'],
|
||||||
|
['{pagedown}', 'PageDown'],
|
||||||
|
['{arrowright}', 'ArrowRight'],
|
||||||
|
['{arrowleft}', 'ArrowLeft'],
|
||||||
|
['{arrowdown}', 'ArrowDown'],
|
||||||
|
['{arrowup}', 'ArrowUp']
|
||||||
|
]);
|
||||||
|
|
||||||
|
// modifier keys
|
||||||
|
export const modifierKeys = [
|
||||||
|
'{shiftleft}',
|
||||||
|
'{controlleft}',
|
||||||
|
'{altleft}',
|
||||||
|
'{metaleft}',
|
||||||
|
'{winleft}',
|
||||||
|
'{shiftright}',
|
||||||
|
'{controlright}',
|
||||||
|
'{altright}',
|
||||||
|
'{metaright}',
|
||||||
|
'{winright}'
|
||||||
|
];
|
||||||
|
|
||||||
|
// double line display buttons
|
||||||
|
export const doubleKeys = [
|
||||||
|
'Backquote',
|
||||||
|
'Digit1',
|
||||||
|
'Digit2',
|
||||||
|
'Digit3',
|
||||||
|
'Digit4',
|
||||||
|
'Digit5',
|
||||||
|
'Digit6',
|
||||||
|
'Digit7',
|
||||||
|
'Digit8',
|
||||||
|
'Digit9',
|
||||||
|
'Digit0',
|
||||||
|
'Minus',
|
||||||
|
'Equal',
|
||||||
|
'BracketLeft',
|
||||||
|
'BracketRight',
|
||||||
|
'Backslash',
|
||||||
|
'Semicolon',
|
||||||
|
'Quote',
|
||||||
|
'Comma',
|
||||||
|
'Period',
|
||||||
|
'Slash'
|
||||||
|
];
|
||||||
53
browser/src/i18n/index.ts
Normal file
53
browser/src/i18n/index.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import type { Resource } from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getLanguage } from '@/libs/storage';
|
||||||
|
|
||||||
|
function getResources(): Resource {
|
||||||
|
const resources: Resource = {};
|
||||||
|
|
||||||
|
const modules: Record<string, Resource> = import.meta.glob('./locales/*.ts', { eager: true });
|
||||||
|
|
||||||
|
for (const path in modules) {
|
||||||
|
const moduleName = path.split('/').pop()?.replace('.ts', '');
|
||||||
|
if (moduleName) {
|
||||||
|
resources[moduleName] = modules[path].default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentLanguage(): string {
|
||||||
|
const languages = Object.keys(resources);
|
||||||
|
|
||||||
|
const cookieLng = getLanguage();
|
||||||
|
if (cookieLng && languages.includes(cookieLng)) {
|
||||||
|
return cookieLng;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigatorLng = navigator.language.split('-')[0];
|
||||||
|
if (languages.includes(navigatorLng)) {
|
||||||
|
return navigatorLng;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = getResources();
|
||||||
|
const lng = getCurrentLanguage();
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources,
|
||||||
|
lng,
|
||||||
|
fallbackLng: 'en',
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then();
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
8
browser/src/i18n/languages.ts
Normal file
8
browser/src/i18n/languages.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const languages = [
|
||||||
|
{ key: 'en', name: 'English' },
|
||||||
|
{ key: 'zh', name: '中文' }
|
||||||
|
];
|
||||||
|
|
||||||
|
languages.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));
|
||||||
|
|
||||||
|
export default languages;
|
||||||
65
browser/src/i18n/locales/en.ts
Normal file
65
browser/src/i18n/locales/en.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
const en = {
|
||||||
|
translation: {
|
||||||
|
serial: {
|
||||||
|
notSupported:
|
||||||
|
'Serial not supported. Please use the desktop Chrome browser to enable mouse and keyboard.',
|
||||||
|
failed: 'Failed to connect serial. Please try again'
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
tip: 'Waiting for authorization...',
|
||||||
|
denied: 'Permission Denied',
|
||||||
|
authorize:
|
||||||
|
'Remote desktop requires camera permission. Please authorize camera in browser settings.',
|
||||||
|
failed: 'Failed to connect camera. Please try again.'
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
title: 'Select USB Device',
|
||||||
|
selectVideo: 'Please select a video input device',
|
||||||
|
selectSerial: 'Select serial device'
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
serial: 'Serial',
|
||||||
|
keyboard: 'Keyboard',
|
||||||
|
mouse: 'Mouse'
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
resolution: 'Resolution',
|
||||||
|
customResolution: 'Custom',
|
||||||
|
device: 'Device',
|
||||||
|
custom: {
|
||||||
|
title: 'Custom Resolution',
|
||||||
|
width: 'Width',
|
||||||
|
height: 'Height',
|
||||||
|
confirm: 'Ok',
|
||||||
|
cancel: 'Cancel'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboard: {
|
||||||
|
paste: 'Paste',
|
||||||
|
virtualKeyboard: 'Keyboard'
|
||||||
|
},
|
||||||
|
mouse: {
|
||||||
|
cursor: {
|
||||||
|
title: 'Cursor',
|
||||||
|
pointer: 'Pointer',
|
||||||
|
grab: 'Grab',
|
||||||
|
cell: 'Cell',
|
||||||
|
hide: 'Hide'
|
||||||
|
},
|
||||||
|
mode: 'Mouse mode',
|
||||||
|
absolute: 'Absolute mode',
|
||||||
|
relative: 'Relative mode',
|
||||||
|
direction: 'Wheel direction',
|
||||||
|
scrollUp: 'Scroll up',
|
||||||
|
scrollDown: 'Scroll down',
|
||||||
|
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.'
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
language: 'Language',
|
||||||
|
document: 'Document',
|
||||||
|
download: 'Download'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default en;
|
||||||
63
browser/src/i18n/locales/zh.ts
Normal file
63
browser/src/i18n/locales/zh.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
const zh = {
|
||||||
|
translation: {
|
||||||
|
serial: {
|
||||||
|
notSupported: '当前浏览器不支持串口,无法使用键鼠。请使用桌面版 Chrome 浏览器。',
|
||||||
|
failed: '串口连接失败,请重试。'
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
tip: '等待授权...',
|
||||||
|
denied: '权限不足',
|
||||||
|
authorize: '远程桌面需要获取摄像头权限,请在浏览器设置中允许使用摄像头。',
|
||||||
|
failed: '摄像头连接失败,请重试。'
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
title: '选择 USB 设备',
|
||||||
|
selectVideo: '请选择视频输入设备',
|
||||||
|
selectSerial: '选择串口设备'
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
serial: '串口',
|
||||||
|
keyboard: '键盘',
|
||||||
|
mouse: '鼠标'
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
resolution: '分辨率',
|
||||||
|
customResolution: '自定义',
|
||||||
|
device: '设备',
|
||||||
|
custom: {
|
||||||
|
title: '自定义分辨率',
|
||||||
|
width: '宽度',
|
||||||
|
height: '高度',
|
||||||
|
confirm: '确定',
|
||||||
|
cancel: '取消'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboard: {
|
||||||
|
paste: '粘贴',
|
||||||
|
virtualKeyboard: '虚拟键盘'
|
||||||
|
},
|
||||||
|
mouse: {
|
||||||
|
cursor: {
|
||||||
|
title: '鼠标指针',
|
||||||
|
pointer: '箭头',
|
||||||
|
grab: '抓取',
|
||||||
|
cell: '单元',
|
||||||
|
hide: '隐藏'
|
||||||
|
},
|
||||||
|
mode: '鼠标模式',
|
||||||
|
absolute: '绝对模式',
|
||||||
|
relative: '相对模式',
|
||||||
|
direction: '滚轮方向',
|
||||||
|
scrollUp: '向上',
|
||||||
|
scrollDown: '向下',
|
||||||
|
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。'
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
language: '语言',
|
||||||
|
document: '文档',
|
||||||
|
download: '下载'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default zh;
|
||||||
16
browser/src/jotai/device.ts
Normal file
16
browser/src/jotai/device.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
import type { Resolution } from '@/types.ts';
|
||||||
|
|
||||||
|
type VideoState = 'disconnected' | 'connecting' | 'connected';
|
||||||
|
type SerialState = 'notSupported' | 'disconnected' | 'connecting' | 'connected';
|
||||||
|
|
||||||
|
export const resolutionAtom = atom<Resolution>({
|
||||||
|
width: 1920,
|
||||||
|
height: 1080
|
||||||
|
});
|
||||||
|
|
||||||
|
export const videoDeviceIdAtom = atom('');
|
||||||
|
export const videoStateAtom = atom<VideoState>('disconnected');
|
||||||
|
|
||||||
|
export const serialStateAtom = atom<SerialState>('disconnected');
|
||||||
5
browser/src/jotai/keyboard.ts
Normal file
5
browser/src/jotai/keyboard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export const isKeyboardEnableAtom = atom(true);
|
||||||
|
|
||||||
|
export const isKeyboardOpenAtom = atom(false);
|
||||||
10
browser/src/jotai/mouse.ts
Normal file
10
browser/src/jotai/mouse.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// mouse cursor style
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export const mouseStyleAtom = atom('cursor-default');
|
||||||
|
|
||||||
|
// mouse mode: absolute or relative
|
||||||
|
export const mouseModeAtom = atom('absolute');
|
||||||
|
|
||||||
|
// mouse scroll direction: 1 or -1
|
||||||
|
export const scrollDirectionAtom = atom(1);
|
||||||
52
browser/src/libs/camera/index.ts
Normal file
52
browser/src/libs/camera/index.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
class Camera {
|
||||||
|
id: string = '';
|
||||||
|
width: number = 1920;
|
||||||
|
height: number = 1080;
|
||||||
|
stream: MediaStream | null = null;
|
||||||
|
|
||||||
|
public async open(id?: string, width?: number, height?: number): Promise<boolean> {
|
||||||
|
if (!id && !this.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
deviceId: { exact: id || this.id },
|
||||||
|
width: { ideal: width || this.width },
|
||||||
|
height: { ideal: height || this.height }
|
||||||
|
},
|
||||||
|
audio: true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
if (id) this.id = id;
|
||||||
|
if (width) this.width = width;
|
||||||
|
if (height) this.height = height;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public close(): void {
|
||||||
|
if (this.stream) {
|
||||||
|
this.stream.getTracks().forEach((track) => track.stop());
|
||||||
|
this.stream = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStream(): MediaStream | null {
|
||||||
|
return this.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isOpen(): boolean {
|
||||||
|
return this.stream !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const camera = new Camera();
|
||||||
66
browser/src/libs/device/index.ts
Normal file
66
browser/src/libs/device/index.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Modifiers } from './keyboard.ts';
|
||||||
|
import { Key as MouseKey, Mode as MouseMode } from './mouse.ts';
|
||||||
|
import { CmdEvent, CmdPacket } from './proto.ts';
|
||||||
|
import { SerialPort } from './serial-port.ts';
|
||||||
|
import { intToLittleEndianList } from './utils.ts';
|
||||||
|
|
||||||
|
export class Device {
|
||||||
|
addr: number;
|
||||||
|
serialPort: SerialPort;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.addr = 0x00;
|
||||||
|
this.serialPort = new SerialPort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendKeyboardData(modifiers: Modifiers, keys: number[]) {
|
||||||
|
if (keys.length !== 6) {
|
||||||
|
throw new Error('keyboard keys length must be 6');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = [modifiers.encode(), 0x00, ...keys];
|
||||||
|
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_KB_GENERAL_DATA, data).encode();
|
||||||
|
|
||||||
|
await this.serialPort.write(cmdData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMouseAbsoluteData(
|
||||||
|
key: MouseKey,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
scroll: number
|
||||||
|
) {
|
||||||
|
const xAbs = width === 0 ? 0 : Math.floor((x * 4096) / width);
|
||||||
|
const yAbs = width === 0 ? 0 : Math.floor((y * 4096) / height);
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
MouseMode.ABSOLUTE,
|
||||||
|
key.encode(),
|
||||||
|
...intToLittleEndianList(xAbs),
|
||||||
|
...intToLittleEndianList(yAbs),
|
||||||
|
scroll
|
||||||
|
];
|
||||||
|
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_MS_ABS_DATA, data).encode();
|
||||||
|
await this.serialPort.write(cmdData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMouseRelativeData(msKey: MouseKey, x: number, y: number, scroll: number) {
|
||||||
|
function intToByte(value: number): number {
|
||||||
|
if (value < -128 || value > 127) {
|
||||||
|
throw new Error('value must be in range -128 to 127 for a signed byte');
|
||||||
|
}
|
||||||
|
return (value + 256) % 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xByte = intToByte(x);
|
||||||
|
const yByte = intToByte(y);
|
||||||
|
|
||||||
|
const data = [MouseMode.RELATIVE, msKey.encode(), xByte, yByte, scroll];
|
||||||
|
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_MS_REL_DATA, data).encode();
|
||||||
|
await this.serialPort.write(cmdData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const device = new Device();
|
||||||
25
browser/src/libs/device/keyboard.ts
Normal file
25
browser/src/libs/device/keyboard.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { setBit } from './utils.ts';
|
||||||
|
|
||||||
|
export class Modifiers {
|
||||||
|
public rightWindows: boolean = false;
|
||||||
|
public rightAlt: boolean = false;
|
||||||
|
public rightShift: boolean = false;
|
||||||
|
public rightCtrl: boolean = false;
|
||||||
|
public leftWindows: boolean = false;
|
||||||
|
public leftAlt: boolean = false;
|
||||||
|
public leftShift: boolean = false;
|
||||||
|
public leftCtrl: boolean = false;
|
||||||
|
|
||||||
|
public encode(): number {
|
||||||
|
let b = 0x00;
|
||||||
|
b = setBit(b, 0, this.leftCtrl);
|
||||||
|
b = setBit(b, 1, this.leftShift);
|
||||||
|
b = setBit(b, 2, this.leftAlt);
|
||||||
|
b = setBit(b, 3, this.leftWindows);
|
||||||
|
b = setBit(b, 4, this.rightCtrl);
|
||||||
|
b = setBit(b, 5, this.rightShift);
|
||||||
|
b = setBit(b, 6, this.rightAlt);
|
||||||
|
b = setBit(b, 7, this.rightWindows);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
browser/src/libs/device/mouse.ts
Normal file
26
browser/src/libs/device/mouse.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { setBit } from './utils.ts';
|
||||||
|
|
||||||
|
export enum Mode {
|
||||||
|
RELATIVE = 0x01,
|
||||||
|
ABSOLUTE = 0x02
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Key {
|
||||||
|
left: boolean;
|
||||||
|
right: boolean;
|
||||||
|
mid: boolean;
|
||||||
|
|
||||||
|
constructor(left: boolean = false, right: boolean = false, mid: boolean = false) {
|
||||||
|
this.left = left;
|
||||||
|
this.right = right;
|
||||||
|
this.mid = mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public encode(): number {
|
||||||
|
let b = 0x00;
|
||||||
|
b = setBit(b, 0, this.left);
|
||||||
|
b = setBit(b, 1, this.right);
|
||||||
|
b = setBit(b, 2, this.mid);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
browser/src/libs/device/proto.ts
Normal file
108
browser/src/libs/device/proto.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
export enum CmdEvent {
|
||||||
|
GET_INFO = 0x01,
|
||||||
|
SEND_KB_GENERAL_DATA = 0x02,
|
||||||
|
SEND_KB_MEDIA_DATA = 0x03,
|
||||||
|
SEND_MS_ABS_DATA = 0x04,
|
||||||
|
SEND_MS_REL_DATA = 0x05,
|
||||||
|
SEND_MY_HID_DATA = 0x06,
|
||||||
|
READ_MY_HID_DATA = 0x87,
|
||||||
|
GET_PARA_CFG = 0x08,
|
||||||
|
SET_PARA_CFG = 0x09,
|
||||||
|
GET_USB_STRING = 0x0a,
|
||||||
|
SET_USB_STRING = 0x0b,
|
||||||
|
SET_DEFAULT_CFG = 0x0c,
|
||||||
|
RESET = 0x0f
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CmdPacket {
|
||||||
|
readonly HEAD1: number = 0x57;
|
||||||
|
readonly HEAD2: number = 0xab;
|
||||||
|
|
||||||
|
ADDR: number = 0x00;
|
||||||
|
CMD: number = 0x00;
|
||||||
|
LEN: number = 0x00;
|
||||||
|
DATA: number[] = [];
|
||||||
|
SUM: number = 0x00;
|
||||||
|
|
||||||
|
constructor(addr: number = 0x00, cmd: number = 0x00, data: number[] = []) {
|
||||||
|
if (addr < 0 || cmd < 0) {
|
||||||
|
this.decode(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.save(addr, cmd, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
encode(): number[] {
|
||||||
|
return [this.HEAD1, this.HEAD2, this.ADDR, this.CMD, this.LEN, ...this.DATA, this.SUM];
|
||||||
|
}
|
||||||
|
|
||||||
|
public decode(data: number[]): number {
|
||||||
|
const headerIndex = this.findHead(data);
|
||||||
|
if (headerIndex < 0) {
|
||||||
|
console.log('cannot find HEAD');
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length - headerIndex < 6) {
|
||||||
|
console.log('len error1');
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addr = data[headerIndex + 2];
|
||||||
|
const cmd = data[headerIndex + 3];
|
||||||
|
const dataLen = data[headerIndex + 4];
|
||||||
|
|
||||||
|
if (data.length < headerIndex + 3 + dataLen + 1) {
|
||||||
|
console.log('len error2');
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sum: number;
|
||||||
|
try {
|
||||||
|
sum = data[headerIndex + 5 + dataLen];
|
||||||
|
} catch {
|
||||||
|
console.log('len error3');
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let s = 0;
|
||||||
|
for (let i = headerIndex; i < headerIndex + 4 + dataLen; i++) {
|
||||||
|
s += data[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((s & 0xff) !== sum) {
|
||||||
|
// console.log(`sum error, sum${sum}, s${s & 0xff}`);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ADDR = addr;
|
||||||
|
this.CMD = cmd;
|
||||||
|
this.LEN = dataLen;
|
||||||
|
this.DATA = data.slice(headerIndex + 5, headerIndex + 5 + this.LEN);
|
||||||
|
this.SUM = sum;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findHead(lst: number[]): number {
|
||||||
|
const subsequence = [this.HEAD1, this.HEAD2];
|
||||||
|
const subseqLen = subsequence.length;
|
||||||
|
for (let i = 0; i <= lst.length - subseqLen; i++) {
|
||||||
|
if (lst.slice(i, i + subseqLen).every((val, index) => val === subsequence[index])) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(addr: number, cmd: number, data: number[]): void {
|
||||||
|
this.ADDR = addr;
|
||||||
|
this.CMD = cmd;
|
||||||
|
this.DATA = data;
|
||||||
|
this.LEN = data.length;
|
||||||
|
this.SUM = this.HEAD1 + this.HEAD2 + this.ADDR + this.CMD + this.LEN;
|
||||||
|
for (const i of this.DATA) {
|
||||||
|
this.SUM += i;
|
||||||
|
}
|
||||||
|
this.SUM &= 0xff;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
browser/src/libs/device/serial-port.ts
Normal file
80
browser/src/libs/device/serial-port.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export class SerialPort {
|
||||||
|
instance: any;
|
||||||
|
reader: ReadableStreamDefaultReader | null;
|
||||||
|
writer: WritableStreamDefaultWriter | null;
|
||||||
|
readonly TIMEOUT = 500; // 500ms
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.instance = null;
|
||||||
|
this.reader = null;
|
||||||
|
this.writer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(port: any, baudRate: number = 57600) {
|
||||||
|
try {
|
||||||
|
if (this.instance) {
|
||||||
|
await this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.instance = port;
|
||||||
|
await this.instance.open({baudRate});
|
||||||
|
|
||||||
|
this.reader = this.instance.readable!.getReader();
|
||||||
|
this.writer = this.instance.writable!.getWriter();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error opening serial port:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(data: number[]) {
|
||||||
|
if (!this.writer) {
|
||||||
|
throw new Error('Serial port not initialized');
|
||||||
|
}
|
||||||
|
const uint8Array = new Uint8Array(data);
|
||||||
|
await this.writer.write(uint8Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(minSize: number, sleep: number = 0): Promise<number[]> {
|
||||||
|
if (!this.reader) {
|
||||||
|
throw new Error('Serial port not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: number[] = [];
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (result.length < minSize) {
|
||||||
|
if (Date.now() - startTime > this.TIMEOUT) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const {value, done} = await this.reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Array.from(value) as number[];
|
||||||
|
result.push(...data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sleep > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, sleep));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.reader) {
|
||||||
|
await this.reader.cancel();
|
||||||
|
this.reader.releaseLock();
|
||||||
|
}
|
||||||
|
if (this.writer) {
|
||||||
|
await this.writer.close();
|
||||||
|
this.writer.releaseLock();
|
||||||
|
}
|
||||||
|
if (this.instance) {
|
||||||
|
await this.instance.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
browser/src/libs/device/utils.ts
Normal file
19
browser/src/libs/device/utils.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export function getBit(number: number, bitPosition: number): number {
|
||||||
|
return (number >> bitPosition) & 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBit(number: number, bitPosition: number, value: boolean): number {
|
||||||
|
if (value) {
|
||||||
|
return number | (1 << bitPosition);
|
||||||
|
} else {
|
||||||
|
return number & ~(1 << bitPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intToLittleEndianList(number: number): number[] {
|
||||||
|
const byteList: number[] = [];
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
byteList.push((number >> (i * 8)) & 0xff);
|
||||||
|
}
|
||||||
|
return byteList;
|
||||||
|
}
|
||||||
107
browser/src/libs/keyboard/charCodes.ts
Normal file
107
browser/src/libs/keyboard/charCodes.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
export const CharCodes: Map<number, number> = new Map([
|
||||||
|
[48, 0x27], // 0
|
||||||
|
[49, 0x1e], // 1
|
||||||
|
[50, 0x1f], // 2
|
||||||
|
[51, 0x20], // 3
|
||||||
|
[52, 0x21], // 4
|
||||||
|
[53, 0x22], // 5
|
||||||
|
[54, 0x23], // 6
|
||||||
|
[55, 0x24], // 7
|
||||||
|
[56, 0x25], // 8
|
||||||
|
[57, 0x26], // 9
|
||||||
|
|
||||||
|
[65, 0x04], // A
|
||||||
|
[66, 0x05], // B
|
||||||
|
[67, 0x06], // C
|
||||||
|
[68, 0x07], // D
|
||||||
|
[69, 0x08], // E
|
||||||
|
[70, 0x09], // F
|
||||||
|
[71, 0x0a], // G
|
||||||
|
[72, 0x0b], // H
|
||||||
|
[73, 0x0c], // I
|
||||||
|
[74, 0x0d], // J
|
||||||
|
[75, 0x0e], // K
|
||||||
|
[76, 0x0f], // L
|
||||||
|
[77, 0x10], // M
|
||||||
|
[78, 0x11], // N
|
||||||
|
[79, 0x12], // O
|
||||||
|
[80, 0x13], // P
|
||||||
|
[81, 0x14], // Q
|
||||||
|
[82, 0x15], // R
|
||||||
|
[83, 0x16], // S
|
||||||
|
[84, 0x17], // T
|
||||||
|
[85, 0x18], // U
|
||||||
|
[86, 0x19], // V
|
||||||
|
[87, 0x1a], // W
|
||||||
|
[88, 0x1b], // X
|
||||||
|
[89, 0x1c], // Y
|
||||||
|
[90, 0x1d], // Z
|
||||||
|
|
||||||
|
[97, 0x04], // a
|
||||||
|
[98, 0x05], // b
|
||||||
|
[99, 0x06], // c
|
||||||
|
[100, 0x07], // d
|
||||||
|
[101, 0x08], // e
|
||||||
|
[102, 0x09], // f
|
||||||
|
[103, 0x0a], // g
|
||||||
|
[104, 0x0b], // h
|
||||||
|
[105, 0x0c], // i
|
||||||
|
[106, 0x0d], // j
|
||||||
|
[107, 0x0e], // k
|
||||||
|
[108, 0x0f], // l
|
||||||
|
[109, 0x10], // m
|
||||||
|
[110, 0x11], // n
|
||||||
|
[111, 0x12], // o
|
||||||
|
[112, 0x13], // p
|
||||||
|
[113, 0x14], // q
|
||||||
|
[114, 0x15], // r
|
||||||
|
[115, 0x16], // s
|
||||||
|
[116, 0x17], // t
|
||||||
|
[117, 0x18], // u
|
||||||
|
[118, 0x19], // v
|
||||||
|
[119, 0x1a], // w
|
||||||
|
[120, 0x1b], // x
|
||||||
|
[121, 0x1c], // y
|
||||||
|
[122, 0x1d], // z
|
||||||
|
|
||||||
|
[32, 0x2c], // Space
|
||||||
|
[33, 0x1e], // !
|
||||||
|
[34, 0x34], // "
|
||||||
|
[35, 0x20], // #
|
||||||
|
[36, 0x21], // $
|
||||||
|
[37, 0x22], // %
|
||||||
|
[38, 0x24], // &
|
||||||
|
[39, 0x34], // '
|
||||||
|
[40, 0x26], // (
|
||||||
|
[41, 0x27], // )
|
||||||
|
[42, 0x25], // *
|
||||||
|
[43, 0x2e], // +
|
||||||
|
[44, 0x36], // ,
|
||||||
|
[45, 0x2d], // -
|
||||||
|
[46, 0x37], // .
|
||||||
|
[47, 0x38], // /
|
||||||
|
|
||||||
|
[9, 43], // Tab
|
||||||
|
[10, 40], // Enter
|
||||||
|
[58, 51], // :
|
||||||
|
[59, 51], // ;
|
||||||
|
[60, 54], // <
|
||||||
|
[61, 46], // =
|
||||||
|
[62, 55], // >
|
||||||
|
[63, 56], // ?
|
||||||
|
[64, 31], // @
|
||||||
|
[91, 47], // [
|
||||||
|
[92, 49], // \
|
||||||
|
[93, 48], // ]
|
||||||
|
[94, 35], // ^
|
||||||
|
[95, 45], // _
|
||||||
|
[96, 53], // `
|
||||||
|
[123, 47], // {
|
||||||
|
[124, 49], // |
|
||||||
|
[125, 48], // }
|
||||||
|
[126, 53] // ~
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ShiftChars: Set<number> = new Set([
|
||||||
|
33, 64, 35, 36, 37, 94, 38, 42, 40, 41, 95, 43, 123, 124, 125, 58, 34, 126, 60, 62, 63
|
||||||
|
]);
|
||||||
2
browser/src/libs/keyboard/index.ts
Normal file
2
browser/src/libs/keyboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './keyboardCodes.ts';
|
||||||
|
export * from './charCodes.ts';
|
||||||
121
browser/src/libs/keyboard/keyboardCodes.ts
Normal file
121
browser/src/libs/keyboard/keyboardCodes.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
export const KeyboardCodes: Map<string, number> = new Map([
|
||||||
|
['KeyA', 4],
|
||||||
|
['KeyB', 5],
|
||||||
|
['KeyC', 6],
|
||||||
|
['KeyD', 7],
|
||||||
|
['KeyE', 8],
|
||||||
|
['KeyF', 9],
|
||||||
|
['KeyG', 10],
|
||||||
|
['KeyH', 11],
|
||||||
|
['KeyI', 12],
|
||||||
|
['KeyJ', 13],
|
||||||
|
['KeyK', 14],
|
||||||
|
['KeyL', 15],
|
||||||
|
['KeyM', 16],
|
||||||
|
['KeyN', 17],
|
||||||
|
['KeyO', 18],
|
||||||
|
['KeyP', 19],
|
||||||
|
['KeyQ', 20],
|
||||||
|
['KeyR', 21],
|
||||||
|
['KeyS', 22],
|
||||||
|
['KeyT', 23],
|
||||||
|
['KeyU', 24],
|
||||||
|
['KeyV', 25],
|
||||||
|
['KeyW', 26],
|
||||||
|
['KeyX', 27],
|
||||||
|
['KeyY', 28],
|
||||||
|
['KeyZ', 29],
|
||||||
|
|
||||||
|
['Digit1', 30],
|
||||||
|
['Digit2', 31],
|
||||||
|
['Digit3', 32],
|
||||||
|
['Digit4', 33],
|
||||||
|
['Digit5', 34],
|
||||||
|
['Digit6', 35],
|
||||||
|
['Digit7', 36],
|
||||||
|
['Digit8', 37],
|
||||||
|
['Digit9', 38],
|
||||||
|
['Digit0', 39],
|
||||||
|
|
||||||
|
['Enter', 40],
|
||||||
|
['Escape', 41],
|
||||||
|
['Backspace', 42],
|
||||||
|
['Tab', 43],
|
||||||
|
['Space', 44],
|
||||||
|
['Minus', 45],
|
||||||
|
['Equal', 46],
|
||||||
|
['BracketLeft', 47],
|
||||||
|
['BracketRight', 48],
|
||||||
|
['Backslash', 49],
|
||||||
|
['IntlBackslash', 49],
|
||||||
|
|
||||||
|
['Semicolon', 51],
|
||||||
|
['Quote', 52],
|
||||||
|
['Backquote', 53],
|
||||||
|
['KeyTilde', 53],
|
||||||
|
['Comma', 54],
|
||||||
|
['Period', 55],
|
||||||
|
['KeyDot', 55],
|
||||||
|
['Slash', 56],
|
||||||
|
['CapsLock', 57],
|
||||||
|
|
||||||
|
['F1', 58],
|
||||||
|
['F2', 59],
|
||||||
|
['F3', 60],
|
||||||
|
['F4', 61],
|
||||||
|
['F5', 62],
|
||||||
|
['F6', 63],
|
||||||
|
['F7', 64],
|
||||||
|
['F8', 65],
|
||||||
|
['F9', 66],
|
||||||
|
['F10', 67],
|
||||||
|
['F11', 68],
|
||||||
|
['F12', 69],
|
||||||
|
['F13', 70],
|
||||||
|
|
||||||
|
['PrintScreen', 70],
|
||||||
|
['ScrollLock', 71],
|
||||||
|
['Pause', 72],
|
||||||
|
['Insert', 73],
|
||||||
|
['Home', 74],
|
||||||
|
['PageUp', 75],
|
||||||
|
['Delete', 76],
|
||||||
|
['End', 77],
|
||||||
|
['PageDown', 78],
|
||||||
|
['ArrowRight', 79],
|
||||||
|
['ArrowLeft', 80],
|
||||||
|
['ArrowDown', 81],
|
||||||
|
['ArrowUp', 82],
|
||||||
|
|
||||||
|
['NumLock', 83],
|
||||||
|
['NumpadDivide', 84],
|
||||||
|
['NumpadMultiply', 85],
|
||||||
|
['NumpadSubtract', 86],
|
||||||
|
['NumpadAdd', 87],
|
||||||
|
['NumpadEnter', 88],
|
||||||
|
['Numpad1', 89],
|
||||||
|
['Numpad2', 90],
|
||||||
|
['Numpad3', 91],
|
||||||
|
['Numpad4', 92],
|
||||||
|
['Numpad5', 93],
|
||||||
|
['Numpad6', 94],
|
||||||
|
['Numpad7', 95],
|
||||||
|
['Numpad8', 96],
|
||||||
|
['Numpad9', 97],
|
||||||
|
['Numpad0', 98],
|
||||||
|
['NumpadDecimal', 99],
|
||||||
|
['KeyKpDot', 99],
|
||||||
|
|
||||||
|
['Menu', 118],
|
||||||
|
|
||||||
|
['ControlLeft', 224],
|
||||||
|
['ShiftLeft', 225],
|
||||||
|
['AltLeft', 226],
|
||||||
|
['MetaLeft', 227],
|
||||||
|
['WinLeft', 227],
|
||||||
|
['ControlRight', 228],
|
||||||
|
['ShiftRight', 229],
|
||||||
|
['AltRight', 230],
|
||||||
|
['MetaRight', 231],
|
||||||
|
['WinRight', 231]
|
||||||
|
]);
|
||||||
92
browser/src/libs/storage/index.ts
Normal file
92
browser/src/libs/storage/index.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Resolution } from '@/types';
|
||||||
|
|
||||||
|
const LANGUAGE_KEY = 'nanokvm-usb-language';
|
||||||
|
const VIDEO_DEVICE_ID_KEY = 'nanokvm-usb-video-device-id';
|
||||||
|
const VIDEO_RESOLUTION_KEY = 'nanokvm-usb-video-resolution';
|
||||||
|
const CUSTOM_RESOLUTION_KEY = 'nanokvm-usb-custom-resolution';
|
||||||
|
const IS_MENU_OPEN_KEY = 'nanokvm-is-menu-open';
|
||||||
|
const MOUSE_STYLE_KEY = 'nanokvm-usb-mouse-style';
|
||||||
|
const MOUSE_MODE_KEY = 'nanokvm-usb-mouse-mode';
|
||||||
|
const MOUSE_SCROLL_DIRECTION_KEY = 'nanokvm-usb-mouse-scroll-direction';
|
||||||
|
|
||||||
|
export function getLanguage() {
|
||||||
|
return localStorage.getItem(LANGUAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLanguage(language: string) {
|
||||||
|
localStorage.setItem(LANGUAGE_KEY, language);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVideoDevice(id: string) {
|
||||||
|
localStorage.setItem(VIDEO_DEVICE_ID_KEY, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoDevice() {
|
||||||
|
return localStorage.getItem(VIDEO_DEVICE_ID_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVideoResolution(width: number, height: number) {
|
||||||
|
localStorage.setItem(VIDEO_RESOLUTION_KEY, window.JSON.stringify({ width, height }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoResolution() {
|
||||||
|
const resolution = localStorage.getItem(VIDEO_RESOLUTION_KEY);
|
||||||
|
if (!resolution) return;
|
||||||
|
return window.JSON.parse(resolution) as Resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomResolutions() {
|
||||||
|
const resolution = localStorage.getItem(CUSTOM_RESOLUTION_KEY);
|
||||||
|
if (!resolution) return;
|
||||||
|
return window.JSON.parse(resolution) as Resolution[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCustomResolution(width: number, height: number) {
|
||||||
|
const resolutions = getCustomResolutions();
|
||||||
|
if (resolutions?.some((r) => r.width === width && r.height === height)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = resolutions ? [...resolutions, { width, height }] : [{ width, height }];
|
||||||
|
localStorage.setItem(CUSTOM_RESOLUTION_KEY, window.JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCustomResolutions() {
|
||||||
|
localStorage.removeItem(CUSTOM_RESOLUTION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIsMenuOpen(): boolean {
|
||||||
|
const state = localStorage.getItem(IS_MENU_OPEN_KEY);
|
||||||
|
if (!state) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return state === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setIsMenuOpen(isOpen: boolean) {
|
||||||
|
localStorage.setItem(IS_MENU_OPEN_KEY, isOpen ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMouseStyle() {
|
||||||
|
return localStorage.getItem(MOUSE_STYLE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMouseStyle(mouse: string) {
|
||||||
|
localStorage.setItem(MOUSE_STYLE_KEY, mouse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMouseMode() {
|
||||||
|
return localStorage.getItem(MOUSE_MODE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMouseMode(mouse: string) {
|
||||||
|
localStorage.setItem(MOUSE_MODE_KEY, mouse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMouseScrollDirection(): string | null {
|
||||||
|
return localStorage.getItem(MOUSE_SCROLL_DIRECTION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMouseScrollDirection(mouse: string): void {
|
||||||
|
localStorage.setItem(MOUSE_SCROLL_DIRECTION_KEY, mouse);
|
||||||
|
}
|
||||||
18
browser/src/main.tsx
Normal file
18
browser/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { ConfigProvider, theme } from 'antd';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import App from './App.tsx';
|
||||||
|
|
||||||
|
import './i18n';
|
||||||
|
import './assets/index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }}>
|
||||||
|
<div className="flex h-screen w-screen flex-col items-center justify-center overflow-hidden">
|
||||||
|
<App />
|
||||||
|
</div>
|
||||||
|
</ConfigProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
4
browser/src/types.ts
Normal file
4
browser/src/types.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type Resolution = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
4
browser/src/vite-env.d.ts
vendored
Normal file
4
browser/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
interface Navigator {
|
||||||
|
serial?: Serial;
|
||||||
|
}
|
||||||
8
browser/tailwind.config.js
Normal file
8
browser/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
30
browser/tsconfig.app.json
Normal file
30
browser/tsconfig.app.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
7
browser/tsconfig.json
Normal file
7
browser/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
11
browser/tsconfig.node.json
Normal file
11
browser/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
2
browser/vite.config.d.ts
vendored
Normal file
2
browser/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
13
browser/vite.config.ts
Normal file
13
browser/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import viteTsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), viteTsconfigPaths()],
|
||||||
|
server: {
|
||||||
|
port: 3001
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 1024
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user