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