refactor: refactor keyboard, mouse and menu bar

This commit is contained in:
wj-xiao
2026-01-20 15:18:52 +08:00
parent 842e1d4578
commit 706b610a78
45 changed files with 4906 additions and 3740 deletions

View File

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

27
browser/eslint.config.js Normal file
View File

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

View File

@@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier --write .",
"preview": "vite preview"
},
"dependencies": {
@@ -15,14 +16,14 @@
"clsx": "^2.1.1",
"i18next": "^24.0.5",
"jotai": "^2.10.3",
"lucide-react": "^0.468.0",
"lucide-react": "^0.562.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.5.0",
"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"
"vaul": "^1.1.1"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
@@ -39,6 +40,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^15.12.0",
"minimatch": "^9.0.5",
"postcss": "^8.4.49",
"prettier": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.6.9",

5504
browser/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
overrides:
minimatch: ^9.0.0
'@babel/helpers@<7.26.10': '>=7.26.10'
'@babel/runtime@<7.26.10': '>=7.26.10'
'@eslint/plugin-kit@<0.3.4': '>=0.3.4'

View File

@@ -7,7 +7,8 @@
@tailwind components;
@tailwind utilities;
html, body {
html,
body {
padding: 0;
margin: 0;
background: #000;

View File

@@ -17,7 +17,9 @@
}
.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);
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 {
@@ -51,7 +53,9 @@
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);
box-shadow:
0 1px 3px 0 rgb(0 0 0 / 0.1),
0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.controlArrows {
@@ -76,7 +80,9 @@
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);
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 {
@@ -90,16 +96,20 @@
.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);
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{
.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";
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';
}

View File

@@ -1,103 +1,173 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { isKeyboardEnableAtom } from '@/jotai/keyboard';
import { getOperatingSystem } from '@/libs/browser';
import { device } from '@/libs/device';
import { Modifiers } from '@/libs/device/keyboard.ts';
import { KeyboardCodes } from '@/libs/keyboard';
import { KeyboardReport } from '@/libs/keyboard/keyboard.ts';
import { isModifier } from '@/libs/keyboard/keymap.ts';
const MAX_SIMULTANEOUS_KEYS = 6;
const ModifierKeys = new Set(['Control', 'Shift', 'Alt', 'Meta']);
interface AltGrState {
active: boolean;
ctrlLeftTimestamp: number;
}
const ALTGR_THRESHOLD_MS = 10;
export const Keyboard = () => {
const pressedKeysRef = useRef<Set<number>>(new Set());
const pressedModifiersRef = useRef<Set<string>>(new Set());
const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom);
const keyboardRef = useRef(new KeyboardReport());
const pressedKeys = useRef(new Set<string>());
const altGrState = useRef<AltGrState | null>(null);
const isComposing = useRef(false);
// listen keyboard events
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', releaseAllKeys);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', releaseAllKeys);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
// press button
async function handleKeyDown(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
if (ModifierKeys.has(event.key)) {
pressedModifiersRef.current.add(event.code);
} else {
const keyCode = KeyboardCodes.get(event.code);
if (
keyCode !== undefined &&
!pressedKeysRef.current.has(keyCode) &&
pressedKeysRef.current.size < MAX_SIMULTANEOUS_KEYS
) {
pressedKeysRef.current.add(keyCode);
}
if (getOperatingSystem() === 'Windows' && !altGrState.current) {
altGrState.current = { active: false, ctrlLeftTimestamp: 0 };
}
await sendKeyData();
}
// release button
async function handleKeyUp(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
if (ModifierKeys.has(event.key)) {
pressedModifiersRef.current.delete(event.code);
} else {
const commonKeyCode = KeyboardCodes.get(event.code);
if (commonKeyCode !== undefined && pressedKeysRef.current.has(commonKeyCode)) {
pressedKeysRef.current.delete(commonKeyCode);
}
}
await sendKeyData();
}
// release all keys when page loses focus
async function releaseAllKeys() {
if (pressedKeysRef.current.size === 0 && pressedModifiersRef.current.size === 0) {
if (!isKeyboardEnabled) {
releaseKeys();
return;
}
const modifiers = new Modifiers();
const keys = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
await device.sendKeyboardData(modifiers, keys);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
document.addEventListener('compositionstart', handleCompositionStart);
document.addEventListener('compositionend', handleCompositionEnd);
window.addEventListener('blur', handleBlur);
document.addEventListener('visibilitychange', handleVisibilityChange);
pressedKeysRef.current.clear();
pressedModifiersRef.current.clear();
}
// Key down event
async function handleKeyDown(event: KeyboardEvent): Promise<void> {
if (!isKeyboardEnabled) return;
// release all keys when page is hidden
function handleVisibilityChange() {
if (document.hidden) {
releaseAllKeys();
// Skip during IME composition
if (isComposing.current || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
const code = event.code;
if (pressedKeys.current.has(code)) {
return;
}
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
if (altGrState.current) {
if (code === 'ControlLeft') {
altGrState.current.ctrlLeftTimestamp = event.timeStamp;
} else if (code === 'AltRight') {
const timeDiff = event.timeStamp - altGrState.current.ctrlLeftTimestamp;
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
pressedKeys.current.delete('ControlLeft');
handleKeyEvent({ type: 'keyup', code: 'ControlLeft' });
altGrState.current.active = true;
}
}
}
pressedKeys.current.add(code);
await handleKeyEvent({ type: 'keydown', code });
}
}
// send keyboard data
async function sendKeyData() {
const modifiers = new Modifiers();
pressedModifiersRef.current.forEach((code) => {
modifiers.setModifier(code);
});
// Key up event
async function handleKeyUp(event: KeyboardEvent): Promise<void> {
if (!isKeyboardEnabled) return;
const keys = [
...Array.from(pressedKeysRef.current),
...new Array(MAX_SIMULTANEOUS_KEYS - pressedKeysRef.current.size).fill(0x00)
];
if (isComposing.current || event.isComposing) return;
await device.sendKeyboardData(modifiers, keys);
event.preventDefault();
event.stopPropagation();
const code = event.code;
// Handle AltGr state for Windows
if (altGrState.current?.active) {
if (code === 'ControlLeft') return;
if (code === 'AltRight') {
altGrState.current.active = false;
}
}
// Compatible with macOS's command key combinations
if (code === 'MetaLeft' || code === 'MetaRight') {
const keysToRelease: string[] = [];
pressedKeys.current.forEach((pressedCode) => {
if (!isModifier(pressedCode)) {
keysToRelease.push(pressedCode);
}
});
for (const key of keysToRelease) {
await handleKeyEvent({ type: 'keyup', code: key });
pressedKeys.current.delete(key);
}
}
pressedKeys.current.delete(code);
await handleKeyEvent({ type: 'keyup', code });
}
// Composition start event
function handleCompositionStart(): void {
isComposing.current = true;
}
// Composition end event
function handleCompositionEnd(): void {
isComposing.current = false;
}
// Release all keys when window loses focus
async function handleBlur(): Promise<void> {
await releaseKeys();
}
// Release all keys before window closes
async function handleVisibilityChange(): Promise<void> {
if (document.hidden) {
await releaseKeys();
}
}
// Release all keys
async function releaseKeys(): Promise<void> {
for (const code of pressedKeys.current) {
await handleKeyEvent({ type: 'keyup', code });
}
pressedKeys.current.clear();
// Reset AltGr state
if (altGrState.current) {
altGrState.current.active = false;
altGrState.current.ctrlLeftTimestamp = 0;
}
const report = keyboardRef.current.reset();
await device.sendKeyboardData(report);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keyup', handleKeyUp);
document.removeEventListener('compositionstart', handleCompositionStart);
document.removeEventListener('compositionend', handleCompositionEnd);
window.removeEventListener('blur', handleBlur);
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseKeys();
};
}, [isKeyboardEnabled]);
// Keyboard handler
async function handleKeyEvent(event: { type: 'keydown' | 'keyup'; code: string }): Promise<void> {
const kb = keyboardRef.current;
const report = event.type === 'keydown' ? kb.keyDown(event.code) : kb.keyUp(event.code);
await device.sendKeyboardData(report);
}
return <></>;

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Divider } from 'antd';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { MenuIcon, XIcon } from 'lucide-react';
import { ChevronRightIcon, GripVerticalIcon, XIcon } from 'lucide-react';
import Draggable from 'react-draggable';
import { serialStateAtom } from '@/jotai/device.ts';
import * as storage from '@/libs/storage';
@@ -18,12 +19,67 @@ import { Video } from './video';
export const Menu = () => {
const serialState = useAtomValue(serialStateAtom);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(true);
const [menuBounds, setMenuBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
const [position, setPosition] = useState({ x: 0, y: 0 });
const [initialized, setInitialized] = useState(false);
const nodeRef = useRef<HTMLDivElement | null>(null);
const handleResize = useCallback(() => {
if (!nodeRef.current) return;
const { innerWidth, innerHeight } = window;
const { offsetWidth, offsetHeight } = nodeRef.current;
setMenuBounds({
left: 0,
top: 0,
right: innerWidth - offsetWidth,
bottom: innerHeight - offsetHeight
});
}, []);
const initializePosition = useCallback(() => {
if (!nodeRef.current || initialized) return;
const { innerWidth } = window;
requestAnimationFrame(() => {
if (!nodeRef.current) return;
const width = nodeRef.current.offsetWidth;
setPosition({
x: (innerWidth - width) / 2,
y: 10
});
setInitialized(true);
});
}, [initialized]);
useEffect(() => {
const isOpen = storage.getIsMenuOpen();
setIsMenuOpen(isOpen);
}, []);
window.addEventListener('resize', handleResize);
const observer = new ResizeObserver(() => {
handleResize();
});
if (nodeRef.current) {
observer.observe(nodeRef.current);
}
return () => {
window.removeEventListener('resize', handleResize);
observer.disconnect();
};
}, [handleResize]);
useEffect(() => {
handleResize();
initializePosition();
}, [isMenuOpen, handleResize, initializePosition]);
function toggleMenu() {
const isOpen = !isMenuOpen;
@@ -33,52 +89,75 @@ export const Menu = () => {
}
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" />
<Recorder />
<Divider type="vertical" />
<Settings />
<Fullscreen />
<Draggable
nodeRef={nodeRef}
bounds={menuBounds}
handle="strong"
position={position}
onDrag={(_, data) => setPosition({ x: data.x, y: data.y })}
>
<div ref={nodeRef} className="fixed left-0 top-0 z-[1000]">
{/* Menubar */}
<div className="sticky top-[10px] flex w-full justify-center">
<div
className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/70 hover:text-white"
onClick={toggleMenu}
className={clsx(
'h-[34px] items-center justify-between space-x-1.5 rounded bg-neutral-800/70 px-2',
isMenuOpen ? 'flex' : 'hidden'
)}
>
<XIcon size={18} />
<strong>
<div className="flex h-[28px] cursor-move select-none items-center justify-center text-neutral-500">
<GripVerticalIcon size={18} />
</div>
</strong>
<Divider type="vertical" />
<Video />
{serialState === 'connected' && (
<>
<SerialPort />
<Divider type="vertical" className="px-0.5" />
<Keyboard />
<Mouse />
</>
)}
<Recorder />
<Divider type="vertical" className="px-0.5" />
<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>
{/* Menubar expand button */}
{!isMenuOpen && (
<div className="flex items-center rounded-lg bg-neutral-800/50 p-1">
<strong>
<div className="flex size-[26px] cursor-move select-none items-center justify-center text-neutral-500">
<GripVerticalIcon size={18} />
</div>
</strong>
<Divider type="vertical" style={{ margin: '0 4px' }} />
<div
className="flex size-[26px] cursor-pointer items-center justify-center rounded text-neutral-500 hover:bg-neutral-700/70 hover:text-white"
onClick={toggleMenu}
>
<ChevronRightIcon size={18} />
</div>
</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>
</Draggable>
);
};

View File

@@ -3,14 +3,14 @@ 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';
import { CharCodes, ShiftChars } from '@/libs/keyboard/charCodes.ts';
import { getModifierBit } from '@/libs/keyboard/keymap.ts';
export const Paste = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
async function paste() {
async function paste(): Promise<void> {
if (isLoading) return;
setIsLoading(true);
@@ -21,15 +21,17 @@ export const Paste = () => {
for (const char of text) {
const ascii = char.charCodeAt(0);
const code = CharCodes.get(ascii);
const code = CharCodes[ascii];
if (!code) continue;
const modifiers = new Modifiers();
if ((ascii >= 65 && ascii <= 90) || ShiftChars.has(ascii)) {
modifiers.leftShift = true;
let modifier = 0;
if ((ascii >= 65 && ascii <= 90) || ShiftChars[ascii]) {
modifier |= getModifierBit('ShiftLeft');
}
await send(modifiers, code);
await send(modifier, code);
await new Promise((r) => setTimeout(r, 50));
await send(0, 0);
}
} catch (e) {
console.log(e);
@@ -38,11 +40,9 @@ export const Paste = () => {
}
}
async function send(modifiers: Modifiers, code: number) {
const keys = [code, 0x00, 0x00, 0x00, 0x00, 0x00];
await device.sendKeyboardData(modifiers, keys);
await device.sendKeyboardData(new Modifiers(), [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
async function send(modifier: number, code: number): Promise<void> {
const keys = [modifier, 0, code, 0, 0, 0, 0, 0];
await device.sendKeyboardData(keys);
}
return (

View File

@@ -14,21 +14,21 @@ export const Shortcuts = () => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [customShortcuts, setCustomShortcuts] = useState<ShortcutInterface[]>([]);
const defaultShortcuts: ShortcutInterface[] = [
{
keys: [
{ code: 'MetaLeft', label: 'Win', isModifier: true },
{ code: 'Tab', label: 'Tab', isModifier: false }
{ code: 'MetaLeft', label: 'Win' },
{ code: 'Tab', label: 'Tab' }
]
},
{
keys: [
{ code: 'ControlLeft', label: 'Ctrl', isModifier: true },
{ code: 'AltLeft', label: 'Alt', isModifier: true },
{ code: 'Delete', label: '⌫', isModifier: false }
{ code: 'ControlLeft', label: 'Ctrl' },
{ code: 'AltLeft', label: 'Alt' },
{ code: 'Delete', label: '⌫' }
]
}
];
@@ -39,18 +39,29 @@ export const Shortcuts = () => {
setCustomShortcuts(JSON.parse(shortcuts));
}, []);
function addShortcut(shortcut: ShortcutInterface) {
function addShortcut(shortcut: ShortcutInterface): void {
const shortcuts = [...customShortcuts, shortcut];
setCustomShortcuts(shortcuts);
storage.setShortcuts(JSON.stringify(shortcuts));
}
function delShortcut(index: number) {
function delShortcut(index: number): void {
const shortcuts = customShortcuts.filter((_, i) => i !== index);
setCustomShortcuts(shortcuts);
storage.setShortcuts(JSON.stringify(shortcuts));
}
function handleOpenChange(open: boolean): void {
if (open) {
setIsOpen(true);
return;
}
if (isRecording) {
return;
}
setIsOpen(false);
}
const content = (
<ScrollArea className="max-w-[400px] [&>[data-radix-scroll-area-viewport]]:max-h-[350px]">
{/* custom shortcuts */}
@@ -71,7 +82,12 @@ export const Shortcuts = () => {
<Divider style={{ margin: '5px 0 5px 0' }} />
<Recorder shortcuts={customShortcuts} addShortcut={addShortcut} delShortcut={delShortcut} />
<Recorder
shortcuts={customShortcuts}
addShortcut={addShortcut}
delShortcut={delShortcut}
setIsRecording={setIsRecording}
/>
</ScrollArea>
);
@@ -82,7 +98,7 @@ export const Shortcuts = () => {
placement="rightTop"
align={{ offset: [14, 0] }}
open={isOpen}
onOpenChange={setIsOpen}
onOpenChange={handleOpenChange}
arrow={false}
>
<div className="flex h-[32px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">

View File

@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx';
import { ScrollArea } from '@/components/ui/scroll-area.tsx';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { modifierKeys } from '@/libs/device/keyboard.ts';
import { isModifier } from '@/libs/keyboard/keymap.ts';
import { KeyInfo, Shortcut } from './types.ts';
@@ -50,14 +50,20 @@ interface RecorderProps {
shortcuts: Shortcut[];
addShortcut: (shortcut: Shortcut) => void;
delShortcut: (index: number) => void;
setIsRecording: (isRecording: boolean) => void;
}
export const Recorder = ({ shortcuts, addShortcut, delShortcut }: RecorderProps) => {
export const Recorder = ({
shortcuts,
addShortcut,
delShortcut,
setIsRecording
}: RecorderProps) => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [shortcutLabel, setShortcutLabel] = useState('');
const inputRef = useRef<InputRef | null>(null);
@@ -65,6 +71,7 @@ export const Recorder = ({ shortcuts, addShortcut, delShortcut }: RecorderProps)
useEffect(() => {
setIsKeyboardEnable(!isModalOpen);
setIsRecording(isModalOpen);
if (!isModalOpen) return;
@@ -78,9 +85,9 @@ export const Recorder = ({ shortcuts, addShortcut, delShortcut }: RecorderProps)
}, [isModalOpen]);
useEffect(() => {
if (!isRecording) return;
if (!isFocused) return;
const handleKeyDown = (event: KeyboardEvent) => {
const handleKeyDown = (event: KeyboardEvent): void => {
event.preventDefault();
event.stopPropagation();
@@ -93,8 +100,7 @@ export const Recorder = ({ shortcuts, addShortcut, delShortcut }: RecorderProps)
const keyInfo: KeyInfo = {
code: code,
label: formatKeyDisplay(key, code),
isModifier: modifierKeys.has(key)
label: formatKeyDisplay(key, code)
};
setShortcutLabel((prev) => (prev ? `${prev} + ${keyInfo.label}` : keyInfo.label));
@@ -106,10 +112,10 @@ export const Recorder = ({ shortcuts, addShortcut, delShortcut }: RecorderProps)
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isRecording]);
}, [isFocused]);
const formatKeyDisplay = (key: string, code: string): string => {
if (modifierKeys.has(key)) {
if (isModifier(code)) {
if (code.startsWith('Control')) {
return 'Ctrl';
}
@@ -208,8 +214,8 @@ export const Recorder = ({ shortcuts, addShortcut, delShortcut }: RecorderProps)
placeholder={t('keyboard.shortcut.capture')}
prefix={<KeyboardIcon size={16} className="text-neutral-500" />}
value={shortcutLabel}
onFocus={() => setIsRecording(true)}
onBlur={() => setIsRecording(false)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<Button onClick={clearShortcut}>{t('keyboard.shortcut.clear')}</Button>

View File

@@ -2,8 +2,7 @@ import { useState } from 'react';
import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx';
import { device } from '@/libs/device';
import { Modifiers } from '@/libs/device/keyboard.ts';
import { KeyboardCodes } from '@/libs/keyboard';
import { KeyboardReport } from '@/libs/keyboard/keyboard.ts';
import type { Shortcut as ShortcutInterface } from './types.ts';
@@ -14,35 +13,12 @@ type ShortcutProps = {
export const Shortcut = ({ shortcut }: ShortcutProps) => {
const [isLoading, setIsLoading] = useState(false);
async function send() {
const modifiers = new Modifiers();
const codes: number[] = [];
shortcut.keys.forEach((key) => {
if (key.isModifier) {
modifiers.setModifier(key.code);
} else {
const code = KeyboardCodes.get(key.code);
if (code) {
codes.push(code);
}
}
});
if (codes.length < 6) {
codes.push(...new Array(6 - codes.length).fill(0x00));
}
await device.sendKeyboardData(modifiers, codes);
await device.sendKeyboardData(new Modifiers(), [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
}
async function handleClick(): Promise<void> {
if (isLoading) return;
setIsLoading(true);
try {
await send();
await sendShortcut();
} catch (err) {
console.log(err);
} finally {
@@ -50,6 +26,18 @@ export const Shortcut = ({ shortcut }: ShortcutProps) => {
}
}
async function sendShortcut(): Promise<void> {
const keyboard = new KeyboardReport();
for (const key of shortcut.keys) {
const report = keyboard.keyDown(key.code);
await device.sendKeyboardData(report);
}
const report = keyboard.reset();
await device.sendKeyboardData(report);
}
return (
<div
className="flex h-[32px] w-full cursor-pointer items-center space-x-1 rounded px-3 hover:bg-neutral-700/30"

View File

@@ -1,7 +1,6 @@
export interface KeyInfo {
code: string;
label: string;
isModifier: boolean;
}
export interface Shortcut {

View File

@@ -1,164 +1,175 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { useMediaQuery } from 'react-responsive';
import { resolutionAtom, videoRotationAtom } from '@/jotai/device.ts';
import { createInitialTouchState, createTouchHandlers } from '@/components/mouse/touchpad.ts';
import { MouseAbsoluteEvent } from '@/components/mouse/types.ts';
import { scrollDirectionAtom, scrollIntervalAtom } from '@/jotai/mouse.ts';
import { device } from '@/libs/device';
import { Key } from '@/libs/device/mouse.ts';
import { MouseAbsoluteRelative } from '@/libs/mouse';
import { mouseJiggler } from '@/libs/mouse-jiggler';
export const Absolute = () => {
const resolution = useAtomValue(resolutionAtom);
const videoRotation = useAtomValue(videoRotationAtom);
const isBigScreen = useMediaQuery({ minWidth: 650 });
const scrollDirection = useAtomValue(scrollDirectionAtom);
const scrollInterval = useAtomValue(scrollIntervalAtom);
const keyRef = useRef<Key>(new Key());
const mouseRef = useRef(new MouseAbsoluteRelative());
const lastPosRef = useRef({ x: 0.5, y: 0.5 });
const lastScrollTimeRef = useRef(0);
const touchStateRef = useRef(createInitialTouchState());
// listen mouse events
useEffect(() => {
const canvas = document.getElementById('video');
if (!canvas) return;
const screen = document.getElementById('video') as HTMLVideoElement;
if (!screen) 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);
// Add mouse event listeners
screen.addEventListener('mousedown', handleMouseDown);
screen.addEventListener('mouseup', handleMouseUp);
screen.addEventListener('mousemove', handleMouseMove);
screen.addEventListener('wheel', handleWheel);
screen.addEventListener('click', disableEvent);
screen.addEventListener('contextmenu', disableEvent);
// press button
async function handleMouseDown(event: any) {
disableEvent(event);
// Create touch handlers
const touchState = touchStateRef.current;
const touchHandlers = createTouchHandlers(touchState, {
scrollDirection,
scrollInterval,
getCoordinate,
handleMouseEvent,
disableEvent
});
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;
// Add touch event listeners (only on big screens)
if (isBigScreen) {
screen.addEventListener('touchstart', touchHandlers.handleTouchStart);
screen.addEventListener('touchmove', touchHandlers.handleTouchMove);
screen.addEventListener('touchend', touchHandlers.handleTouchEnd);
screen.addEventListener('touchcancel', touchHandlers.handleTouchCancel);
}
// Mouse down event
function handleMouseDown(e: MouseEvent): void {
disableEvent(e);
handleMouseEvent({ type: 'mousedown', button: e.button });
}
// Mouse up event
function handleMouseUp(e: MouseEvent): void {
disableEvent(e);
handleMouseEvent({ type: 'mouseup', button: e.button });
}
// Mouse move event
function handleMouseMove(e: MouseEvent): void {
disableEvent(e);
const { x, y } = getCoordinate(e);
handleMouseEvent({ type: 'move', x, y });
}
// Mouse wheel event
function handleWheel(e: WheelEvent): void {
disableEvent(e);
if (Math.floor(e.deltaY) === 0) {
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);
mouseJiggler.moveEventCallback();
}
// mouse scroll
async function handleWheel(event: any) {
disableEvent(event);
const currentTime = Date.now();
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
return;
}
const delta = Math.floor(event.deltaY);
if (delta === 0) return;
await send(event, delta > 0 ? -1 * scrollDirection : scrollDirection);
const deltaY = (e.deltaY > 0 ? 1 : -1) * scrollDirection;
handleMouseEvent({ type: 'wheel', deltaY });
lastScrollTimeRef.current = currentTime;
}
async function send(event: MouseEvent, scroll: number = 0) {
const { x, y } = getCorrectedCoords(event.clientX, event.clientY);
await device.sendMouseAbsoluteData(keyRef.current, 1, 1, x, y, scroll);
}
// Calculate mouse coordinate
function getCoordinate(event: { clientX: number; clientY: number }): { x: number; y: number } {
const rect = screen.getBoundingClientRect();
function getCorrectedCoords(clientX: number, clientY: number) {
if (!canvas) {
return { x: 0, y: 0 };
const clientX = event.clientX;
const clientY = event.clientY;
if (!screen.videoWidth || !screen.videoHeight) {
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
return { x, y };
}
const rect = canvas.getBoundingClientRect();
const videoElement = canvas as HTMLVideoElement;
const videoRatio = screen.videoWidth / screen.videoHeight;
const elementRatio = rect.width / rect.height;
const videoWidth = videoElement.videoWidth || 1920;
const videoHeight = videoElement.videoHeight || 1080;
let renderedWidth = rect.width;
let renderedHeight = rect.height;
let offsetX = 0;
let offsetY = 0;
const screenVideoRatio =
videoRotation === 90 || videoRotation === 270
? videoHeight / videoWidth
: videoWidth / videoHeight;
const containerWidth = rect.width;
const containerHeight = rect.height;
const containerRatio = containerWidth / containerHeight;
let renderedWidth: number;
let renderedHeight: number;
if (screenVideoRatio > containerRatio) {
renderedWidth = containerWidth;
renderedHeight = containerWidth / screenVideoRatio;
if (videoRatio > elementRatio) {
renderedHeight = rect.width / videoRatio;
offsetY = (rect.height - renderedHeight) / 2;
} else {
renderedHeight = containerHeight;
renderedWidth = containerHeight * screenVideoRatio;
renderedWidth = rect.height * videoRatio;
offsetX = (rect.width - renderedWidth) / 2;
}
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const relX = clientX - centerX;
const relY = clientY - centerY;
let x = relX / renderedWidth + 0.5;
let y = relY / renderedHeight + 0.5;
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
const x = (clientX - rect.left - offsetX) / renderedWidth;
const y = (clientY - rect.top - offsetY) / renderedHeight;
return { x, y };
}
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, scrollInterval, videoRotation]);
screen.removeEventListener('mousedown', handleMouseDown);
screen.removeEventListener('mouseup', handleMouseUp);
screen.removeEventListener('mousemove', handleMouseMove);
screen.removeEventListener('wheel', handleWheel);
screen.removeEventListener('click', disableEvent);
screen.removeEventListener('contextmenu', disableEvent);
screen.removeEventListener('touchstart', touchHandlers.handleTouchStart);
screen.removeEventListener('touchmove', touchHandlers.handleTouchMove);
screen.removeEventListener('touchend', touchHandlers.handleTouchEnd);
screen.removeEventListener('touchcancel', touchHandlers.handleTouchCancel);
// disable default events
function disableEvent(event: any) {
touchHandlers.cleanup();
};
}, [isBigScreen, scrollDirection, scrollInterval]);
// Mouse event handler
async function handleMouseEvent(event: MouseAbsoluteEvent): Promise<void> {
let report: number[];
const mouse = mouseRef.current;
switch (event.type) {
case 'mousedown':
mouse.buttonDown(event.button);
report = mouse.buildButtonReport(lastPosRef.current.x, lastPosRef.current.y);
break;
case 'mouseup':
mouse.buttonUp(event.button);
report = mouse.buildButtonReport(lastPosRef.current.x, lastPosRef.current.y);
break;
case 'wheel':
report = mouse.buildReport(lastPosRef.current.x, lastPosRef.current.y, event.deltaY);
break;
case 'move':
report = mouse.buildReport(event.x, event.y);
lastPosRef.current = { x: event.x, y: event.y };
break;
default:
report = mouse.buildReport(lastPosRef.current.x, lastPosRef.current.y);
break;
}
await device.sendMouseData([0x02, ...report]);
mouseJiggler.moveEventCallback();
}
// Disable default events
function disableEvent(event: Event): void {
event.preventDefault();
event.stopPropagation();
}

View File

@@ -3,27 +3,144 @@ import { message } from 'antd';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { resolutionAtom } from '@/jotai/device.ts';
import { MouseRelativeEvent } from '@/components/mouse/types.ts';
import { scrollDirectionAtom, scrollIntervalAtom } from '@/jotai/mouse.ts';
import { device } from '@/libs/device';
import { Key } from '@/libs/device/mouse.ts';
import { MouseReportRelative } from '@/libs/mouse';
import { mouseJiggler } from '@/libs/mouse-jiggler';
export const Relative = () => {
const { t } = useTranslation();
const [messageApi, contextHolder] = message.useMessage();
const resolution = useAtomValue(resolutionAtom);
const scrollDirection = useAtomValue(scrollDirectionAtom);
const scrollInterval = useAtomValue(scrollIntervalAtom);
const mouseRef = useRef(new MouseReportRelative());
const isLockedRef = useRef(false);
const keyRef = useRef<Key>(new Key());
const lastScrollTimeRef = useRef(0);
showMessage();
useEffect(() => {
const screen = document.getElementById('video');
if (!screen) return;
screen.addEventListener('click', handleClick);
screen.addEventListener('mousedown', handleMouseDown);
screen.addEventListener('mouseup', handleMouseUp);
screen.addEventListener('mousemove', handleMouseMove);
screen.addEventListener('wheel', handleMouseWheel);
screen.addEventListener('contextmenu', disableEvent);
document.addEventListener('pointerlockchange', handlePointerLockChange);
// Click to request pointer lock
function handleClick(event: MouseEvent): void {
disableEvent(event);
if (!isLockedRef.current) {
screen?.requestPointerLock();
}
}
// Mouse down event
function handleMouseDown(e: MouseEvent): void {
disableEvent(e);
handleMouseEvent({ type: 'mousedown', button: e.button });
}
// Mouse up event
function handleMouseUp(e: MouseEvent): void {
disableEvent(e);
handleMouseEvent({ type: 'mouseup', button: e.button });
}
// Mouse move event
function handleMouseMove(e: MouseEvent): void {
disableEvent(e);
const x = e.movementX || 0;
const y = e.movementY || 0;
if (x === 0 && y === 0) return;
const deltaX = Math.abs(x * window.devicePixelRatio) < 10 ? x * 2 : x;
const deltaY = Math.abs(y * window.devicePixelRatio) < 10 ? y * 2 : y;
handleMouseEvent({ type: 'move', deltaX, deltaY });
}
// Mouse wheel event
function handleMouseWheel(e: WheelEvent): void {
disableEvent(e);
if (Math.floor(e.deltaY) === 0) {
return;
}
const currentTime = Date.now();
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
return;
}
const deltaY = (e.deltaY > 0 ? 1 : -1) * scrollDirection;
handleMouseEvent({ type: 'wheel', deltaY });
lastScrollTimeRef.current = currentTime;
}
// Pointer lock state change
function handlePointerLockChange(): void {
isLockedRef.current = document.pointerLockElement === screen;
}
return (): void => {
// Exit pointer lock when component unmounts
if (document.pointerLockElement === screen) {
document.exitPointerLock();
}
screen.removeEventListener('click', handleClick);
screen.removeEventListener('mousedown', handleMouseDown);
screen.removeEventListener('mouseup', handleMouseUp);
screen.removeEventListener('mousemove', handleMouseMove);
screen.removeEventListener('wheel', handleMouseWheel);
screen.removeEventListener('contextmenu', disableEvent);
document.removeEventListener('pointerlockchange', handlePointerLockChange);
};
}, [scrollDirection, scrollInterval]);
// Mouse handler
async function handleMouseEvent(event: MouseRelativeEvent): Promise<void> {
let report: number[];
const mouse = mouseRef.current;
switch (event.type) {
case 'mousedown':
mouse.buttonDown(event.button);
report = mouse.buildButtonReport();
break;
case 'mouseup':
mouse.buttonUp(event.button);
report = mouse.buildButtonReport();
break;
case 'wheel':
report = mouse.buildReport(0, 0, event.deltaY);
break;
case 'move':
report = mouse.buildReport(event.deltaX, event.deltaY);
break;
default:
report = mouse.buildReport(0, 0);
break;
}
await device.sendMouseData([0x01, ...report]);
mouseJiggler.moveEventCallback();
}
function showMessage(): void {
messageApi.open({
key: 'relative',
key: 'requestPointer',
type: 'info',
content: t('mouse.requestPointer'),
duration: 3,
@@ -31,124 +148,9 @@ export const Relative = () => {
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);
mouseJiggler.moveEventCallback();
}
// mouse scroll
async function handleWheel(event: any) {
disableEvent(event);
const currentTime = Date.now();
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
return;
}
const delta = Math.floor(event.deltaY);
if (delta === 0) return;
await send(0, 0, delta > 0 ? -1 * scrollDirection : scrollDirection);
lastScrollTimeRef.current = currentTime;
}
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, scrollInterval]);
async function send(x: number, y: number, scroll: number) {
await device.sendMouseRelativeData(keyRef.current, x, y, scroll);
}
// disable default events
function disableEvent(event: any) {
function disableEvent(event: Event): void {
event.preventDefault();
event.stopPropagation();
}

View File

@@ -0,0 +1,205 @@
import { MouseButton } from './types';
import type { MouseAbsoluteEvent } from './types';
// Touch event thresholds
const TAP_THRESHOLD = 8;
const DRAG_THRESHOLD = 10;
const VELOCITY_THRESHOLD = 0.3;
const LONG_PRESS_DELAY = 800;
export interface TouchHandlerOptions {
scrollDirection: number;
scrollInterval: number;
getCoordinate: (event: { clientX: number; clientY: number }) => { x: number; y: number };
handleMouseEvent: (event: MouseAbsoluteEvent) => void;
disableEvent: (event: Event) => void;
}
export interface TouchState {
touchStartTime: number;
lastTouchY: number;
longPressTimer: ReturnType<typeof setTimeout> | null;
isLongPress: boolean;
hasMove: boolean;
isDragging: boolean;
pressedButton: MouseButton | null;
touchStartPos: { x: number; y: number };
lastScrollTime: number;
}
export function createInitialTouchState(): TouchState {
return {
touchStartTime: 0,
lastTouchY: 0,
longPressTimer: null,
isLongPress: false,
hasMove: false,
isDragging: false,
pressedButton: null,
touchStartPos: { x: 0, y: 0 },
lastScrollTime: 0
};
}
export function createTouchHandlers(state: TouchState, options: TouchHandlerOptions) {
const { scrollDirection, scrollInterval, getCoordinate, handleMouseEvent, disableEvent } =
options;
function handleTouchStart(e: TouchEvent): void {
disableEvent(e);
if (e.touches.length === 0) {
return;
}
const touch = e.touches[0];
// Reset states
state.touchStartTime = Date.now();
state.lastTouchY = touch.clientY;
state.isLongPress = false;
state.hasMove = false;
state.isDragging = false;
state.pressedButton = null;
state.touchStartPos = { x: touch.clientX, y: touch.clientY };
if (state.longPressTimer) {
clearTimeout(state.longPressTimer);
}
const { x, y } = getCoordinate(touch);
handleMouseEvent({ type: 'move', x, y });
if (e.touches.length > 1) {
return;
}
// Start long press timer
state.longPressTimer = setTimeout(() => {
state.isLongPress = true;
state.pressedButton = MouseButton.Right;
if (navigator.vibrate) {
navigator.vibrate(50);
}
handleMouseEvent({ type: 'mousedown', button: MouseButton.Right });
}, LONG_PRESS_DELAY);
}
function handleTouchMove(e: TouchEvent): void {
disableEvent(e);
if (e.touches.length === 0) {
return;
}
const touch = e.touches[0];
// Handle two-finger scroll first
if (e.touches.length > 1) {
const currentTime = Date.now();
if (currentTime - state.lastScrollTime < scrollInterval) {
return;
}
const deltaY = (touch.clientY - state.lastTouchY > 0 ? 1 : -1) * scrollDirection;
handleMouseEvent({ type: 'wheel', deltaY });
state.lastTouchY = touch.clientY;
state.lastScrollTime = currentTime;
return;
}
const deltaX = Math.abs(touch.clientX - state.touchStartPos.x);
const deltaY = Math.abs(touch.clientY - state.touchStartPos.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const timeDelta = Date.now() - state.touchStartTime;
const velocity = timeDelta > 0 ? distance / timeDelta : 0;
const shouldStartDrag =
distance > DRAG_THRESHOLD || (distance > TAP_THRESHOLD && velocity > VELOCITY_THRESHOLD);
if (shouldStartDrag && !state.isDragging && !state.isLongPress) {
if (!state.hasMove) {
state.hasMove = true;
}
if (state.longPressTimer) {
clearTimeout(state.longPressTimer);
state.longPressTimer = null;
}
if (state.pressedButton === null) {
state.isDragging = true;
state.pressedButton = MouseButton.Left;
handleMouseEvent({ type: 'mousedown', button: MouseButton.Left });
}
}
if (distance > TAP_THRESHOLD && !state.hasMove) {
state.hasMove = true;
}
if (state.isDragging || state.isLongPress) {
const { x, y } = getCoordinate(touch);
handleMouseEvent({ type: 'move', x, y });
}
}
function handleTouchEnd(e: TouchEvent): void {
disableEvent(e);
if (state.longPressTimer) {
clearTimeout(state.longPressTimer);
state.longPressTimer = null;
}
if (!state.hasMove && !state.isLongPress) {
handleMouseEvent({ type: 'mousedown', button: MouseButton.Left });
setTimeout(() => {
handleMouseEvent({ type: 'mouseup', button: MouseButton.Left });
}, 50);
} else if (state.pressedButton !== null) {
handleMouseEvent({ type: 'mouseup', button: state.pressedButton });
}
resetTouchState();
}
function handleTouchCancel(e: TouchEvent): void {
disableEvent(e);
if (state.longPressTimer) {
clearTimeout(state.longPressTimer);
state.longPressTimer = null;
}
if (state.pressedButton !== null) {
handleMouseEvent({ type: 'mouseup', button: state.pressedButton });
}
resetTouchState();
}
function resetTouchState(): void {
state.isLongPress = false;
state.hasMove = false;
state.isDragging = false;
state.pressedButton = null;
}
function cleanup(): void {
if (state.longPressTimer) {
clearTimeout(state.longPressTimer);
state.longPressTimer = null;
}
}
return {
handleTouchStart,
handleTouchMove,
handleTouchEnd,
handleTouchCancel,
cleanup
};
}

View File

@@ -0,0 +1,33 @@
export enum MouseButton {
Left = 0,
Middle = 1,
Right = 2,
Back = 3,
Forward = 4
}
interface MouseMoveAbsoluteEvent {
type: 'move';
x: number;
y: number;
}
interface MouseMoveRelativeEvent {
type: 'move';
deltaX: number;
deltaY: number;
}
interface MouseButtonEvent {
type: 'mousedown' | 'mouseup';
button: number;
}
interface MouseWheelEvent {
type: 'wheel';
deltaY: number;
}
export type MouseAbsoluteEvent = MouseMoveAbsoluteEvent | MouseButtonEvent | MouseWheelEvent;
export type MouseRelativeEvent = MouseMoveRelativeEvent | MouseButtonEvent | MouseWheelEvent;

View File

@@ -10,8 +10,7 @@ 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 { KeyboardReport } from '@/libs/keyboard/keyboard.ts';
import {
doubleKeys,
@@ -19,7 +18,7 @@ import {
keyboardControlPadOptions,
keyboardOptions,
modifierKeys,
specialKeyMap
specialKeys
} from './keys.ts';
type KeyboardProps = {
@@ -31,60 +30,56 @@ export const VirtualKeyboard = ({ isBigScreen }: KeyboardProps) => {
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
const keyboardRef = useRef<any>(null);
const keyboardRef = useRef(new KeyboardReport());
async function onKeyPress(key: string) {
if (modifierKeys.includes(key)) {
if (activeModifierKeys.includes(key)) {
await sendKeydown(key);
await sendKeyup();
} else {
// Key down event
async function onKeyPress(key: string): Promise<void> {
if (modifierKeys[key]) {
if (!activeModifierKeys.includes(key)) {
// Save modifier key
setActiveModifierKeys([...activeModifierKeys, key]);
} else {
// Press and release modifier keys
for (const modifierKey of activeModifierKeys) {
await handleKeyEvent({ type: 'keydown', key: modifierKey });
}
for (const modifierKey of activeModifierKeys) {
await handleKeyEvent({ type: 'keyup', key: modifierKey });
}
setActiveModifierKeys([]);
}
return;
}
await sendKeydown(key);
for (const modifierKey of activeModifierKeys) {
await handleKeyEvent({ type: 'keydown', key: modifierKey });
}
await handleKeyEvent({ type: 'keydown', key });
}
async function onKeyReleased(key: string) {
if (modifierKeys.includes(key)) {
// Key up event
async function onKeyReleased(key: string): Promise<void> {
// Skip modifier key
if (modifierKeys[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;
for (const modifierKey of activeModifierKeys) {
await handleKeyEvent({ type: 'keyup', key: modifierKey });
}
const modifiers = getModifiers();
const keys = [code, 0x00, 0x00, 0x00, 0x00, 0x00];
await device.sendKeyboardData(modifiers, keys);
}
async function sendKeyup() {
const modifiers = new Modifiers();
const keys = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
await device.sendKeyboardData(modifiers, keys);
await handleKeyEvent({ type: 'keyup', key });
setActiveModifierKeys([]);
}
function getModifiers() {
const modifiers = new Modifiers();
async function handleKeyEvent(event: { type: 'keydown' | 'keyup'; key: string }): Promise<void> {
const code = specialKeys[event.key] ?? event.key;
activeModifierKeys.forEach((modifierKey) => {
const code = specialKeyMap.get(modifierKey)!;
modifiers.setModifier(code);
});
const kb = keyboardRef.current;
const report = event.type === 'keydown' ? kb.keyDown(code) : kb.keyUp(code);
return modifiers;
await device.sendKeyboardData(report);
}
function getButtonTheme(): KeyboardButtonTheme[] {
@@ -123,7 +118,6 @@ export const VirtualKeyboard = ({ isBigScreen }: KeyboardProps) => {
{/* main keyboard */}
<Keyboard
buttonTheme={getButtonTheme()}
keyboardRef={(r: any) => (keyboardRef.current = r)}
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
layoutName="default"

View File

@@ -127,51 +127,51 @@ export const keyboardArrowsOptions = {
};
// 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']
]);
export const specialKeys: Record<string, string> = {
'{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}'
];
export const modifierKeys: Record<string, string> = {
'{shiftleft}': 'ShiftLeft',
'{controlleft}': 'ControlLeft',
'{altleft}': 'AltLeft',
'{metaleft}': 'MetaLeft',
'{winleft}': 'WinLeft',
'{shiftright}': 'ShiftRight',
'{controlright}': 'ControlRight',
'{altright}': 'AltRight',
'{metaright}': 'MetaRight',
'{winright}': 'WinRight'
};
// double line display buttons
export const doubleKeys = [

View File

@@ -1,72 +1,73 @@
const be = {
translation: {
serial: {
notSupported:
'Seriële poort wordt niet ondersteund. Gebruik de desktop Chrome-browser om muis en klavier te gebruiken.',
failed: 'Verbinding met seriële poort mislukt. Probeer opnieuw'
},
camera: {
tip: 'Wachten op toelating...',
denied: 'Toelating geweigerd',
authorize:
'De externe desktop vereist cameratoegang. Geef toelating voor de camera in de browserinstellingen.',
failed: 'Kan geen verbinding maken met de camera. Probeer opnieuw.'
},
modal: {
title: 'Kies USB-apparaat',
selectVideo: 'Kies een video-invoerapparaat',
selectSerial: 'Kies serieel apparaat'
},
menu: {
serial: 'Serieel',
keyboard: 'Klavier',
mouse: 'Muis'
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
customResolution: 'Aangepast',
device: 'Toestel',
custom: {
title: 'Aangepaste resolutie',
width: 'Breedte',
height: 'Hoogte',
confirm: 'OK',
cancel: 'Annuleren'
}
},
keyboard: {
paste: 'Plakken',
virtualKeyboard: 'Virtueel klavier',
shortcut: {
title: 'Sneltoetsen',
ctrlAltDel: 'Ctrl + Alt + Delete',
ctrlD: 'Ctrl + D',
winTab: 'Win + Tab',
},
},
mouse: {
cursor: {
title: 'Muisaanwijzer',
pointer: 'Wijzer',
grab: 'Hand',
cell: 'Kruis',
hide: 'Verborgen'
},
mode: 'Muismodus',
absolute: 'Absolute modus',
relative: 'Relatieve modus',
direction: 'Scrollrichting',
scrollUp: 'Omhoog scrollen',
scrollDown: 'Omlaag scrollen',
requestPointer: 'Gebruik relatieve modus. Klik op het bureaublad om de muisaanwijzer vast te nemen.'
},
settings: {
language: 'Taal',
document: 'Document',
download: 'Download'
translation: {
serial: {
notSupported:
'Seriële poort wordt niet ondersteund. Gebruik de desktop Chrome-browser om muis en klavier te gebruiken.',
failed: 'Verbinding met seriële poort mislukt. Probeer opnieuw'
},
camera: {
tip: 'Wachten op toelating...',
denied: 'Toelating geweigerd',
authorize:
'De externe desktop vereist cameratoegang. Geef toelating voor de camera in de browserinstellingen.',
failed: 'Kan geen verbinding maken met de camera. Probeer opnieuw.'
},
modal: {
title: 'Kies USB-apparaat',
selectVideo: 'Kies een video-invoerapparaat',
selectSerial: 'Kies serieel apparaat'
},
menu: {
serial: 'Serieel',
keyboard: 'Klavier',
mouse: 'Muis'
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
customResolution: 'Aangepast',
device: 'Toestel',
custom: {
title: 'Aangepaste resolutie',
width: 'Breedte',
height: 'Hoogte',
confirm: 'OK',
cancel: 'Annuleren'
}
},
keyboard: {
paste: 'Plakken',
virtualKeyboard: 'Virtueel klavier',
shortcut: {
title: 'Sneltoetsen',
ctrlAltDel: 'Ctrl + Alt + Delete',
ctrlD: 'Ctrl + D',
winTab: 'Win + Tab'
}
},
mouse: {
cursor: {
title: 'Muisaanwijzer',
pointer: 'Wijzer',
grab: 'Hand',
cell: 'Kruis',
hide: 'Verborgen'
},
mode: 'Muismodus',
absolute: 'Absolute modus',
relative: 'Relatieve modus',
direction: 'Scrollrichting',
scrollUp: 'Omhoog scrollen',
scrollDown: 'Omlaag scrollen',
requestPointer:
'Gebruik relatieve modus. Klik op het bureaublad om de muisaanwijzer vast te nemen.'
},
settings: {
language: 'Taal',
document: 'Document',
download: 'Download'
}
};
}
};
export default be;
export default be;

View File

@@ -1,75 +1,76 @@
const de = {
translation: {
serial: {
notSupported:
'Seriell nicht unterstützt. Bitte benutze den Desktop Chrome Browser um Maus und Tastatur zu verwenden.',
failed: 'Verbindung zu Seriell fehlgeschlagen. Bitte erneut versuchen'
},
camera: {
tip: 'Warten auf Berechtigung...',
denied: 'Berechtigung verweigert',
authorize:
'Der Remote-Desktop erfordert eine Kamerazulassung. Bitte autorisieren Sie die Kamera in den Browsereinstellungen.',
failed: 'Kamera konnte nicht verbunden werden. Bitte versuche es erneut.'
},
modal: {
title: 'USB-Gerät auswählen',
selectVideo: 'Bitte wähle ein Video-Eingabegerät aus',
selectSerial: 'Serielles Gerät auswählen'
},
menu: {
serial: 'Seriell',
keyboard: 'Tastatur',
mouse: 'Maus'
},
video: {
resolution: 'Auflösung',
scale: 'Skalierung',
customResolution: 'Benutzerdefiniert',
device: 'Gerät',
custom: {
title: 'Benutzerdefinierte Aufösung',
width: 'Breite',
height: 'Höhe',
confirm: 'Ok',
cancel: 'Abbrechen'
}
},
keyboard: {
paste: 'Einfügen',
virtualKeyboard: 'Virtuelle Tastatur',
shortcut: {
title: 'Tastenkürzel',
ctrlAltDel: 'Strg + Alt + Entfernen',
ctrlD: 'Strg + D',
winTab: 'Win + Tab',
},
},
mouse: {
cursor: {
title: 'Mauszeiger',
pointer: 'Zeiger',
grab: 'Hand',
cell: 'Kreuz',
hide: 'Versteckt'
},
mode: 'Mausmodus',
absolute: 'Absoluter Modus',
relative: 'Relativer Mode',
direction: 'Scrollrichtung',
scrollUp: 'Hochscrollen',
scrollDown: 'Runterscrollen',
speed: 'Scrollgeschwindigkeit',
fast: 'Schnell',
slow: 'Langsam',
requestPointer: 'Benutze relativen Modus. Bitte auf den Desktop klicken, um den Mauszeiger anzuzeigen.'
},
settings: {
language: 'Sprache',
document: 'Dokument',
download: 'Download'
translation: {
serial: {
notSupported:
'Seriell nicht unterstützt. Bitte benutze den Desktop Chrome Browser um Maus und Tastatur zu verwenden.',
failed: 'Verbindung zu Seriell fehlgeschlagen. Bitte erneut versuchen'
},
camera: {
tip: 'Warten auf Berechtigung...',
denied: 'Berechtigung verweigert',
authorize:
'Der Remote-Desktop erfordert eine Kamerazulassung. Bitte autorisieren Sie die Kamera in den Browsereinstellungen.',
failed: 'Kamera konnte nicht verbunden werden. Bitte versuche es erneut.'
},
modal: {
title: 'USB-Gerät auswählen',
selectVideo: 'Bitte wähle ein Video-Eingabegerät aus',
selectSerial: 'Serielles Gerät auswählen'
},
menu: {
serial: 'Seriell',
keyboard: 'Tastatur',
mouse: 'Maus'
},
video: {
resolution: 'Auflösung',
scale: 'Skalierung',
customResolution: 'Benutzerdefiniert',
device: 'Gerät',
custom: {
title: 'Benutzerdefinierte Aufösung',
width: 'Breite',
height: 'Höhe',
confirm: 'Ok',
cancel: 'Abbrechen'
}
},
keyboard: {
paste: 'Einfügen',
virtualKeyboard: 'Virtuelle Tastatur',
shortcut: {
title: 'Tastenkürzel',
ctrlAltDel: 'Strg + Alt + Entfernen',
ctrlD: 'Strg + D',
winTab: 'Win + Tab'
}
},
mouse: {
cursor: {
title: 'Mauszeiger',
pointer: 'Zeiger',
grab: 'Hand',
cell: 'Kreuz',
hide: 'Versteckt'
},
mode: 'Mausmodus',
absolute: 'Absoluter Modus',
relative: 'Relativer Mode',
direction: 'Scrollrichtung',
scrollUp: 'Hochscrollen',
scrollDown: 'Runterscrollen',
speed: 'Scrollgeschwindigkeit',
fast: 'Schnell',
slow: 'Langsam',
requestPointer:
'Benutze relativen Modus. Bitte auf den Desktop klicken, um den Mauszeiger anzuzeigen.'
},
settings: {
language: 'Sprache',
document: 'Dokument',
download: 'Download'
}
};
}
};
export default de;
export default de;

View File

@@ -41,8 +41,8 @@ const ko = {
shortcut: {
ctrlAltDel: 'Ctrl + Alt + Delete',
ctrlD: 'Ctrl + D',
winTab: 'Win + Tab',
},
winTab: 'Win + Tab'
}
},
mouse: {
cursor: {

View File

@@ -1,72 +1,73 @@
const nl = {
translation: {
serial: {
notSupported:
'Seriële poort wordt niet ondersteund. Gebruik de desktop Chrome-browser om muis en toetsenbord te gebruiken.',
failed: 'Verbinding met seriële poort mislukt. Probeer het opnieuw'
},
camera: {
tip: 'Wachten op toestemming...',
denied: 'Toestemming geweigerd',
authorize:
'De externe desktop vereist cameratoegang. Geef toestemming voor de camera in de browserinstellingen.',
failed: 'Kan geen verbinding maken met de camera. Probeer het opnieuw.'
},
modal: {
title: 'Selecteer USB-apparaat',
selectVideo: 'Selecteer een video-invoerapparaat',
selectSerial: 'Selecteer serieel apparaat'
},
menu: {
serial: 'Serieel',
keyboard: 'Toetsenbord',
mouse: 'Muis'
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
customResolution: 'Aangepast',
device: 'Apparaat',
custom: {
title: 'Aangepaste resolutie',
width: 'Breedte',
height: 'Hoogte',
confirm: 'OK',
cancel: 'Annuleren'
}
},
keyboard: {
paste: 'Plakken',
virtualKeyboard: 'Virtueel toetsenbord',
shortcut: {
title: 'Sneltoetsen',
ctrlAltDel: 'Ctrl + Alt + Delete',
ctrlD: 'Ctrl + D',
winTab: 'Win + Tab',
},
},
mouse: {
cursor: {
title: 'Muiscursor',
pointer: 'Aanwijzer',
grab: 'Hand',
cell: 'Kruis',
hide: 'Verborgen'
},
mode: 'Muismodus',
absolute: 'Absolute modus',
relative: 'Relatieve modus',
direction: 'Scrollrichting',
scrollUp: 'Omhoog scrollen',
scrollDown: 'Omlaag scrollen',
requestPointer: 'Gebruik relatieve modus. Klik op het bureaublad om de muisaanwijzer vast te leggen.'
},
settings: {
language: 'Taal',
document: 'Document',
download: 'Download'
translation: {
serial: {
notSupported:
'Seriële poort wordt niet ondersteund. Gebruik de desktop Chrome-browser om muis en toetsenbord te gebruiken.',
failed: 'Verbinding met seriële poort mislukt. Probeer het opnieuw'
},
camera: {
tip: 'Wachten op toestemming...',
denied: 'Toestemming geweigerd',
authorize:
'De externe desktop vereist cameratoegang. Geef toestemming voor de camera in de browserinstellingen.',
failed: 'Kan geen verbinding maken met de camera. Probeer het opnieuw.'
},
modal: {
title: 'Selecteer USB-apparaat',
selectVideo: 'Selecteer een video-invoerapparaat',
selectSerial: 'Selecteer serieel apparaat'
},
menu: {
serial: 'Serieel',
keyboard: 'Toetsenbord',
mouse: 'Muis'
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
customResolution: 'Aangepast',
device: 'Apparaat',
custom: {
title: 'Aangepaste resolutie',
width: 'Breedte',
height: 'Hoogte',
confirm: 'OK',
cancel: 'Annuleren'
}
},
keyboard: {
paste: 'Plakken',
virtualKeyboard: 'Virtueel toetsenbord',
shortcut: {
title: 'Sneltoetsen',
ctrlAltDel: 'Ctrl + Alt + Delete',
ctrlD: 'Ctrl + D',
winTab: 'Win + Tab'
}
},
mouse: {
cursor: {
title: 'Muiscursor',
pointer: 'Aanwijzer',
grab: 'Hand',
cell: 'Kruis',
hide: 'Verborgen'
},
mode: 'Muismodus',
absolute: 'Absolute modus',
relative: 'Relatieve modus',
direction: 'Scrollrichting',
scrollUp: 'Omhoog scrollen',
scrollDown: 'Omlaag scrollen',
requestPointer:
'Gebruik relatieve modus. Klik op het bureaublad om de muisaanwijzer vast te leggen.'
},
settings: {
language: 'Taal',
document: 'Document',
download: 'Download'
}
};
}
};
export default nl;
export default nl;

View File

@@ -40,8 +40,8 @@ const pl = {
shortcut: {
ctrlAltDel: 'Ctrl + Alt + Delete',
ctrlD: 'Ctrl + D',
winTab: 'Win + Tab',
},
winTab: 'Win + Tab'
}
},
mouse: {
cursor: {

View File

@@ -40,8 +40,8 @@ const pt_br = {
shortcut: {
ctrlAltDel: 'Ctrl + Alt + Delete',
ctrlD: 'Ctrl + D',
winTab: 'Win + Tab',
},
winTab: 'Win + Tab'
}
},
mouse: {
cursor: {
@@ -60,7 +60,8 @@ const pt_br = {
speed: 'Velocidade do scroll',
fast: 'Rápido',
slow: 'Lento',
requestPointer: 'Usando modo relativo. Por favor, clique na área de trabalho para restaurar o ponteiro do mouse.'
requestPointer:
'Usando modo relativo. Por favor, clique na área de trabalho para restaurar o ponteiro do mouse.'
},
settings: {
language: 'Linguagem',

View File

@@ -3,7 +3,8 @@ const ru = {
serial: {
notSupported:
'Подключение к последовательному порту не поддерживается. Чтобы пользоваться мышью и клавиатурой, используйте браузер Chrome для настольных компьютеров.',
failed: 'Не удалось установить подключение к последовательному порту. Пожалуйста, попробуйте еще раз.'
failed:
'Не удалось установить подключение к последовательному порту. Пожалуйста, попробуйте еще раз.'
},
camera: {
tip: 'Ожидание доступа...',
@@ -42,8 +43,8 @@ const ru = {
title: 'Сочетания клавиш',
ctrlAltDel: 'Ctrl + Alt + Delete',
ctrlD: 'Ctrl + D',
winTab: 'Win + Tab',
},
winTab: 'Win + Tab'
}
},
mouse: {
cursor: {
@@ -62,7 +63,8 @@ const ru = {
speed: 'Скорость прокрутки',
fast: 'Быстро',
slow: 'Медленно',
requestPointer: 'Используется относительное позиционирование мыши. Чтобы захватить курсор, щелкните по видео на экране'
requestPointer:
'Используется относительное позиционирование мыши. Чтобы захватить курсор, щелкните по видео на экране'
},
settings: {
language: 'Язык приложения',

View File

@@ -38,8 +38,8 @@ const zh_tw = {
shortcut: {
ctrlAltDel: 'Ctrl + Alt + Delete',
ctrlD: 'Ctrl + D',
winTab: 'Win + Tab',
},
winTab: 'Win + Tab'
}
},
mouse: {
cursor: {

View File

@@ -7,7 +7,7 @@ export const mouseStyleAtom = atom('cursor-default');
export const mouseModeAtom = atom('absolute');
// mouse scroll direction: 1 or -1
export const scrollDirectionAtom = atom(1);
export const scrollDirectionAtom = atom(-1);
// mouse scroll interval (unit: ms)
// mouse scroll interval (unit: ms)

View File

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

View File

@@ -1,8 +1,5 @@
import { Modifiers } from './keyboard.ts';
import { Key as MouseKey, Mode as MouseMode } from './mouse.ts';
import { CmdEvent, CmdPacket, InfoPacket } from './proto.ts';
import { SerialPort } from './serial-port.ts';
import { intToByte, intToLittleEndianList } from './utils.ts';
export class Device {
addr: number;
@@ -22,45 +19,16 @@ export class Device {
return new InfoPacket(rspPacket.DATA);
}
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();
async sendKeyboardData(report: number[]): Promise<void> {
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_KB_GENERAL_DATA, report).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);
async sendMouseData(report: number[]): Promise<void> {
if (report.length === 0) return;
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) {
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();
const cmdEvent = report[0] === 0x01 ? CmdEvent.SEND_MS_REL_DATA : CmdEvent.SEND_MS_ABS_DATA;
const cmdData = new CmdPacket(this.addr, cmdEvent, report).encode();
await this.serialPort.write(cmdData);
}
}

View File

@@ -1,58 +0,0 @@
import { setBit } from './utils.ts';
const modifierKeys = new Set(['Control', 'Shift', 'Alt', 'Meta']);
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;
}
public setModifier(code: string) {
switch (code) {
case 'ControlLeft':
this.leftCtrl = true;
return;
case 'ControlRight':
this.rightCtrl = true;
return;
case 'ShiftLeft':
this.leftShift = true;
return;
case 'ShiftRight':
this.rightShift = true;
return;
case 'AltLeft':
this.leftAlt = true;
return;
case 'AltRight':
this.rightAlt = true;
return;
case 'MetaLeft':
this.leftWindows = true;
return;
case 'MetaRight':
this.rightWindows = true;
return;
}
}
}
export { Modifiers, modifierKeys };

View File

@@ -1,26 +0,0 @@
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;
}
}

View File

@@ -1,5 +1,3 @@
import { getBit } from './utils.ts';
export enum CmdEvent {
GET_INFO = 0x01,
SEND_KB_GENERAL_DATA = 0x02,
@@ -132,3 +130,7 @@ export class InfoPacket {
this.SCROLL_LOCK = getBit(data[2], 2) === 1;
}
}
function getBit(number: number, bitPosition: number): number {
return (number >> bitPosition) & 1;
}

View File

@@ -17,7 +17,7 @@ export class SerialPort {
}
this.instance = port;
await this.instance.open({baudRate});
await this.instance.open({ baudRate });
this.reader = this.instance.readable!.getReader();
this.writer = this.instance.writable!.getWriter();
@@ -48,7 +48,7 @@ export class SerialPort {
return [];
}
const {value, done} = await this.reader.read();
const { value, done } = await this.reader.read();
if (done) {
break;
}

View File

@@ -1,26 +0,0 @@
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 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;
}
export function intToLittleEndianList(number: number): number[] {
const byteList: number[] = [];
for (let i = 0; i < 2; i++) {
byteList.push((number >> (i * 8)) & 0xff);
}
return byteList;
}

View File

@@ -1,107 +1,127 @@
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
export const CharCodes: Record<number, number> = {
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
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
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], // /
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] // ~
]);
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
]);
export const ShiftChars: Record<number, boolean> = {
33: true, // !
64: true, // @
35: true, // #
36: true, // $
37: true, // %
94: true, // ^
38: true, // &
42: true, // *
40: true, // (
41: true, // )
95: true, // _
43: true, // +
123: true, // {
124: true, // |
125: true, // }
58: true, // :
34: true, // "
126: true, // ~
60: true, // <
62: true, // >
63: true // ?
};

View File

@@ -1,2 +0,0 @@
export * from './keyboardCodes.ts';
export * from './charCodes.ts';

View File

@@ -0,0 +1,63 @@
import { getKeycode, getModifierBit, isModifier } from './keymap';
const MAX_KEYS = 6;
export class KeyboardReport {
private modifier: number = 0;
private pressedKeys: Map<string, number> = new Map();
keyDown(code: string): number[] {
if (isModifier(code)) {
this.modifier |= getModifierBit(code);
} else {
const keycode = getKeycode(code);
if (keycode !== undefined && this.pressedKeys.size < MAX_KEYS) {
this.pressedKeys.set(code, keycode);
}
}
return this.buildReport();
}
keyUp(code: string): number[] {
if (isModifier(code)) {
this.modifier &= ~getModifierBit(code);
} else {
this.pressedKeys.delete(code);
}
return this.buildReport();
}
reset(): number[] {
this.modifier = 0;
this.pressedKeys.clear();
return this.buildReport();
}
/**
* Build the 8-byte HID keyboard report
* Byte 0: Modifier keys bitmap
* Byte 1: Reserved (0x00)
* Bytes 2-7: Up to 6 keycodes
*/
private buildReport(): number[] {
const report = [this.modifier, 0, 0, 0, 0, 0, 0, 0];
let i = 2;
for (const keycode of this.pressedKeys.values()) {
if (i >= 8) break;
report[i++] = keycode;
}
return report;
}
getModifier(): number {
return this.modifier;
}
getPressedKeyCount(): number {
return this.pressedKeys.size;
}
}
export const keyboard = new KeyboardReport();

View File

@@ -1,241 +0,0 @@
export const KeyboardCodes: Map<string, number> = new Map([
// Letters
['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],
// Numbers
['Digit1', 30],
['Digit2', 31],
['Digit3', 32],
['Digit4', 33],
['Digit5', 34],
['Digit6', 35],
['Digit7', 36],
['Digit8', 37],
['Digit9', 38],
['Digit0', 39],
// Special keys
['Enter', 40],
['Escape', 41],
['Backspace', 42],
['Tab', 43],
['Space', 44],
['Minus', 45],
['Equal', 46],
['BracketLeft', 47],
['BracketRight', 48],
['Backslash', 49],
['Semicolon', 51],
['Quote', 52],
['Backquote', 53],
['Comma', 54],
['Period', 55],
['Slash', 56],
['CapsLock', 57],
// ISO keyboard specific
['IntlHash', 50],
// Function keys
['F1', 58],
['F2', 59],
['F3', 60],
['F4', 61],
['F5', 62],
['F6', 63],
['F7', 64],
['F8', 65],
['F9', 66],
['F10', 67],
['F11', 68],
['F12', 69],
// Control keys
['PrintScreen', 70],
['ScrollLock', 71],
['Pause', 72],
['Insert', 73],
['Home', 74],
['PageUp', 75],
['Delete', 76],
['End', 77],
['PageDown', 78],
// Arrow keys
['ArrowRight', 79],
['ArrowLeft', 80],
['ArrowDown', 81],
['ArrowUp', 82],
// Numpad
['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],
// International / Non-US keyboard keys
['IntlBackslash', 100],
['ContextMenu', 101],
['Power', 102],
['NumpadEqual', 103],
// Extended function keys
['F13', 104],
['F14', 105],
['F15', 106],
['F16', 107],
['F17', 108],
['F18', 109],
['F19', 110],
['F20', 111],
['F21', 112],
['F22', 113],
['F23', 114],
['F24', 115],
// System / Edit keys
['Execute', 116],
['Help', 117],
['Props', 118],
['Select', 119],
['Stop', 120],
['Again', 121],
['Undo', 122],
['Cut', 123],
['Copy', 124],
['Paste', 125],
['Find', 126],
// Media / Volume keys
['AudioVolumeMute', 127],
['AudioVolumeUp', 128],
['AudioVolumeDown', 129],
['VolumeMute', 127],
['VolumeUp', 128],
['VolumeDown', 129],
// Locking keys (for keyboards with physical lock keys)
['LockingCapsLock', 130],
['LockingNumLock', 131],
['LockingScrollLock', 132],
// Numpad additional
['NumpadComma', 133],
['NumpadEqual2', 134],
// International keys - Japanese
['IntlRo', 135],
['KanaMode', 136],
['IntlYen', 137],
['Convert', 138],
['NonConvert', 139],
// International keys - Additional Japanese
['International6', 140],
['International7', 141],
['International8', 142],
['International9', 143],
// Language keys - Korean/Japanese/Chinese
['Lang1', 144],
['Lang2', 145],
['Lang3', 146],
['Lang4', 147],
['Lang5', 148],
['Lang6', 149],
['Lang7', 150],
['Lang8', 151],
['Lang9', 152],
// Numpad extended
['NumpadParenLeft', 182],
['NumpadParenRight', 183],
['NumpadBackspace', 187],
['NumpadMemoryStore', 208],
['NumpadMemoryRecall', 209],
['NumpadMemoryClear', 210],
['NumpadMemoryAdd', 211],
['NumpadMemorySubtract', 212],
['NumpadClear', 216],
['NumpadClearEntry', 217],
// Modifier keys
['ControlLeft', 224],
['ShiftLeft', 225],
['AltLeft', 226],
['MetaLeft', 227],
['WinLeft', 227],
['ControlRight', 228],
['ShiftRight', 229],
['AltRight', 230],
['MetaRight', 231],
['WinRight', 231],
// Media keys
['MediaPlayPause', 232],
['MediaStop', 233],
['MediaTrackPrevious', 234],
['MediaTrackNext', 235],
['Eject', 236],
['MediaSelect', 237],
// Application launch keys
['LaunchMail', 238],
['LaunchApp1', 239],
['LaunchApp2', 240],
// Additional browser/system keys
['BrowserSearch', 240],
['BrowserHome', 241],
['BrowserBack', 242],
['BrowserForward', 243],
['BrowserStop', 244],
['BrowserRefresh', 245],
['BrowserFavorites', 246],
// Sleep/Wake keys
['Sleep', 248],
['Wake', 249],
// Accessibility keys
['MediaRewind', 250],
['MediaFastForward', 251]
]);

View File

@@ -0,0 +1,281 @@
// Modifier key bit positions
export const ModifierBits = {
LeftCtrl: 1 << 0,
LeftShift: 1 << 1,
LeftAlt: 1 << 2,
LeftMeta: 1 << 3,
RightCtrl: 1 << 4,
RightShift: 1 << 5,
RightAlt: 1 << 6,
RightMeta: 1 << 7
} as const;
// Map event.code to HID modifier bit
export const ModifierMap: Record<string, number> = {
ControlLeft: ModifierBits.LeftCtrl,
ShiftLeft: ModifierBits.LeftShift,
AltLeft: ModifierBits.LeftAlt,
MetaLeft: ModifierBits.LeftMeta,
ControlRight: ModifierBits.RightCtrl,
ShiftRight: ModifierBits.RightShift,
AltRight: ModifierBits.RightAlt,
MetaRight: ModifierBits.RightMeta
};
// Map event.code to HID keycode
export const KeycodeMap: Record<string, number> = {
// Letters
KeyA: 0x04,
KeyB: 0x05,
KeyC: 0x06,
KeyD: 0x07,
KeyE: 0x08,
KeyF: 0x09,
KeyG: 0x0a,
KeyH: 0x0b,
KeyI: 0x0c,
KeyJ: 0x0d,
KeyK: 0x0e,
KeyL: 0x0f,
KeyM: 0x10,
KeyN: 0x11,
KeyO: 0x12,
KeyP: 0x13,
KeyQ: 0x14,
KeyR: 0x15,
KeyS: 0x16,
KeyT: 0x17,
KeyU: 0x18,
KeyV: 0x19,
KeyW: 0x1a,
KeyX: 0x1b,
KeyY: 0x1c,
KeyZ: 0x1d,
// Numbers
Digit1: 0x1e,
Digit2: 0x1f,
Digit3: 0x20,
Digit4: 0x21,
Digit5: 0x22,
Digit6: 0x23,
Digit7: 0x24,
Digit8: 0x25,
Digit9: 0x26,
Digit0: 0x27,
// Special keys
Enter: 0x28,
Escape: 0x29,
Backspace: 0x2a,
Tab: 0x2b,
Space: 0x2c,
Minus: 0x2d,
Equal: 0x2e,
BracketLeft: 0x2f,
BracketRight: 0x30,
Backslash: 0x31,
Semicolon: 0x33,
Quote: 0x34,
Backquote: 0x35,
Comma: 0x36,
Period: 0x37,
Slash: 0x38,
CapsLock: 0x39,
// Function keys
F1: 0x3a,
F2: 0x3b,
F3: 0x3c,
F4: 0x3d,
F5: 0x3e,
F6: 0x3f,
F7: 0x40,
F8: 0x41,
F9: 0x42,
F10: 0x43,
F11: 0x44,
F12: 0x45,
// Control keys
PrintScreen: 0x46,
ScrollLock: 0x47,
Pause: 0x48,
Insert: 0x49,
Home: 0x4a,
PageUp: 0x4b,
Delete: 0x4c,
End: 0x4d,
PageDown: 0x4e,
// Arrow keys
ArrowRight: 0x4f,
ArrowLeft: 0x50,
ArrowDown: 0x51,
ArrowUp: 0x52,
// Numpad
NumLock: 0x53,
NumpadDivide: 0x54,
NumpadMultiply: 0x55,
NumpadSubtract: 0x56,
NumpadAdd: 0x57,
NumpadEnter: 0x58,
Numpad1: 0x59,
Numpad2: 0x5a,
Numpad3: 0x5b,
Numpad4: 0x5c,
Numpad5: 0x5d,
Numpad6: 0x5e,
Numpad7: 0x5f,
Numpad8: 0x60,
Numpad9: 0x61,
Numpad0: 0x62,
NumpadDecimal: 0x63,
// International / Non-US keyboard keys
IntlBackslash: 0x64,
ContextMenu: 0x65,
Power: 0x66,
NumpadEqual: 0x67,
// Extended function keys
F13: 0x68,
F14: 0x69,
F15: 0x6a,
F16: 0x6b,
F17: 0x6c,
F18: 0x6d,
F19: 0x6e,
F20: 0x6f,
F21: 0x70,
F22: 0x71,
F23: 0x72,
F24: 0x73,
// System / Edit keys
Execute: 0x74,
Help: 0x75,
Props: 0x76,
Select: 0x77,
Stop: 0x78,
Again: 0x79,
Undo: 0x7a,
Cut: 0x7b,
Copy: 0x7c,
Paste: 0x7d,
Find: 0x7e,
// Media / Volume keys
AudioVolumeMute: 0x7f,
AudioVolumeUp: 0x80,
AudioVolumeDown: 0x81,
VolumeMute: 0x7f, // Alias
VolumeUp: 0x80, // Alias
VolumeDown: 0x81, // Alias
// Locking keys (for keyboards with physical lock keys)
LockingCapsLock: 0x82,
LockingNumLock: 0x83,
LockingScrollLock: 0x84,
// Numpad additional
NumpadComma: 0x85,
NumpadEqual2: 0x86, // AS/400 keyboard equal key
// International keys - Japanese
IntlRo: 0x87, // Japanese Ro key (ろ)
KanaMode: 0x88, // Katakana/Hiragana toggle
IntlYen: 0x89, // Japanese Yen (¥)
Convert: 0x8a, // Japanese Henkan (変換)
NonConvert: 0x8b, // Japanese Muhenkan (無変換)
// International keys - Additional Japanese
International6: 0x8c,
International7: 0x8d,
International8: 0x8e,
International9: 0x8f,
// Language keys - Korean/Japanese/Chinese
Lang1: 0x90, // Korean Hangul/English toggle
Lang2: 0x91, // Korean Hanja
Lang3: 0x92, // Japanese Katakana
Lang4: 0x93, // Japanese Hiragana
Lang5: 0x94, // Japanese Zenkaku/Hankaku
Lang6: 0x95,
Lang7: 0x96,
Lang8: 0x97,
Lang9: 0x98,
// ISO keyboard specific
IntlHash: 0x32, // Non-US # and ~ (ISO keyboards)
// Numpad extended
NumpadParenLeft: 0xb6,
NumpadParenRight: 0xb7,
NumpadBackspace: 0xbb,
NumpadMemoryStore: 0xd0,
NumpadMemoryRecall: 0xd1,
NumpadMemoryClear: 0xd2,
NumpadMemoryAdd: 0xd3,
NumpadMemorySubtract: 0xd4,
NumpadClear: 0xd8,
NumpadClearEntry: 0xd9,
// Additional browser/system keys
BrowserSearch: 0xf0,
BrowserHome: 0xf1,
BrowserBack: 0xf2,
BrowserForward: 0xf3,
BrowserStop: 0xf4,
BrowserRefresh: 0xf5,
BrowserFavorites: 0xf6,
// Media keys
MediaPlayPause: 0xe8,
MediaStop: 0xe9,
MediaTrackPrevious: 0xea,
MediaTrackNext: 0xeb,
Eject: 0xec,
MediaSelect: 0xed,
// Application launch keys
LaunchMail: 0xee,
LaunchApp1: 0xef,
LaunchApp2: 0xf0,
// Sleep/Wake keys
Sleep: 0xf8,
Wake: 0xf9,
// Accessibility keys
MediaRewind: 0xfa,
MediaFastForward: 0xfb,
// Modifier keys (HID Usage Page 0x07, codes 0xE0-0xE7)
ControlLeft: 0xe0,
ShiftLeft: 0xe1,
AltLeft: 0xe2,
MetaLeft: 0xe3,
WinLeft: 0xe3,
ControlRight: 0xe4,
ShiftRight: 0xe5,
AltRight: 0xe6,
MetaRight: 0xe7,
WinRight: 0xe7
};
// Check if code is a modifier key
export function isModifier(code: string): boolean {
return code in ModifierMap;
}
// Get modifier bit for code
export function getModifierBit(code: string): number {
return ModifierMap[code] ?? 0;
}
// Get keycode for code
export function getKeycode(code: string): number | undefined {
return KeycodeMap[code];
}

View File

@@ -1,18 +1,19 @@
import { device } from '@/libs/device';
import { Key } from '@/libs/device/mouse.ts';
import { MouseReportRelative } from '@/libs/mouse';
const MOUSE_JIGGLER_INTERVAL = 15_000;
const EMPTY_KEY: Key = new Key(false, false, false);
class MouseJiggler {
private lastMoveTime: number;
private timer: number | null;
private mode: 'enable' | 'disable';
private mouseReport: MouseReportRelative;
constructor() {
this.lastMoveTime = Date.now();
this.timer = null;
this.mode = 'disable';
this.mouseReport = new MouseReportRelative();
}
// enable or disable mouse jiggler
@@ -43,8 +44,11 @@ class MouseJiggler {
}
async sendJiggle(): Promise<void> {
await device.sendMouseRelativeData(EMPTY_KEY, 10, 10, 0);
await device.sendMouseRelativeData(EMPTY_KEY, -10, -10, 0);
const report1 = this.mouseReport.buildReport(10, 10, 0);
const report2 = this.mouseReport.buildReport(-10, -10, 0);
await device.sendKeyboardData([0x01, ...report1]);
await device.sendKeyboardData([0x01, ...report2]);
}
}

View File

@@ -0,0 +1,134 @@
// Maximum absolute coordinate value
const MAX_ABS_COORD = 4096;
// Button bit positions
const MouseButtons = {
Left: 1 << 0,
Right: 1 << 1,
Middle: 1 << 2,
Back: 1 << 3,
Forward: 1 << 4
} as const;
// Map browser button index to HID bit
function getMouseButtonBit(button: number): number {
switch (button) {
case 0:
return MouseButtons.Left;
case 1:
return MouseButtons.Middle;
case 2:
return MouseButtons.Right;
case 3:
return MouseButtons.Back;
case 4:
return MouseButtons.Forward;
default:
return 0;
}
}
/**
* Relative Mouse Report (4 bytes)
*
* Byte 0: Buttons
* Byte 1: X movement (-127 to 127)
* Byte 2: Y movement (-127 to 127)
* Byte 3: Wheel (-127 to 127)
*/
export class MouseReportRelative {
private buttons: number = 0;
buttonDown(button: number): void {
this.buttons |= getMouseButtonBit(button);
}
buttonUp(button: number): void {
this.buttons &= ~getMouseButtonBit(button);
}
/**
* Build relative mouse report
* @param deltaX X movement (-127 to 127)
* @param deltaY Y movement (-127 to 127)
* @param wheel Scroll wheel (-127 to 127, negative = down)
*/
buildReport(deltaX: number, deltaY: number, wheel: number = 0): number[] {
const x = this.clamp(Math.round(deltaX), -127, 127) & 0xff;
const y = this.clamp(Math.round(deltaY), -127, 127) & 0xff;
const scroll = this.clamp(Math.round(wheel), -127, 127) & 0xff;
return [this.buttons, x, y, scroll];
}
/**
* Build button-only report (no movement)
*/
buildButtonReport(): number[] {
return this.buildReport(0, 0, 0);
}
reset(): number[] {
this.buttons = 0;
return this.buildReport(0, 0, 0);
}
private clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
}
/**
* Absolute Mouse Report (6 bytes)
*
* Byte 0: Buttons
* Byte 1-2: X position (0 to 32767, Little Endian)
* Byte 3-4: Y position (0 to 32767, Little Endian)
* Byte 5: Wheel
*/
export class MouseAbsoluteRelative {
private buttons: number = 0;
buttonDown(button: number): void {
this.buttons |= getMouseButtonBit(button);
}
buttonUp(button: number): void {
this.buttons &= ~getMouseButtonBit(button);
}
/**
* Build absolute mouse report
* @param x X position (0.0 to 1.0, normalized)
* @param y Y position (0.0 to 1.0, normalized)
* @param wheel Scroll wheel (-127 to 127)
*/
buildReport(x: number, y: number, wheel: number = 0): number[] {
// Convert normalized coordinates (0-1) to absolute coordinates (0-4096)
const xAbs = Math.floor(Math.max(0, Math.min(1, x)) * MAX_ABS_COORD);
const yAbs = Math.floor(Math.max(0, Math.min(1, y)) * MAX_ABS_COORD);
const x1 = xAbs & 0xff;
const x2 = (xAbs >> 8) & 0xff;
const y1 = yAbs & 0xff;
const y2 = (yAbs >> 8) & 0xff;
const scroll = this.clamp(Math.round(wheel), -127, 127) & 0xff;
return [this.buttons, x1, x2, y1, y2, scroll];
}
/**
* Build button-only report (keeps last position)
*/
buildButtonReport(lastX: number, lastY: number): number[] {
return this.buildReport(lastX, lastY, 0);
}
reset(): number[] {
this.buttons = 0;
return this.buildReport(0, 0, 0);
}
private clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
}

View File

@@ -1,7 +1,4 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View File

@@ -1,2 +1,2 @@
declare const _default: import("vite").UserConfig;
declare const _default: import('vite').UserConfig;
export default _default;