mirror of
https://github.com/sipeed/NanoKVM-USB.git
synced 2026-09-11 05:59:57 -05:00
refactor(desktop): refactor mouse module, add touchscreen support
This commit is contained in:
@@ -14,8 +14,8 @@ export const Direction = (): ReactElement => {
|
||||
const [scrollDirection, setScrollDirection] = useAtom(scrollDirectionAtom);
|
||||
|
||||
const directions = [
|
||||
{ name: t('mouse.scrollUp'), value: '1' },
|
||||
{ name: t('mouse.scrollDown'), value: '-1' }
|
||||
{ name: t('mouse.scrollUp'), value: '-1' },
|
||||
{ name: t('mouse.scrollDown'), value: '1' }
|
||||
];
|
||||
|
||||
function update(direction: string): void {
|
||||
|
||||
@@ -10,8 +10,7 @@ export enum IpcEvents {
|
||||
OPEN_SERIAL_PORT_RSP = 'open-serial-port-rsp',
|
||||
CLOSE_SERIAL_PORT = 'close-serial-port',
|
||||
SEND_KEYBOARD = 'send-keyboard',
|
||||
SEND_MOUSE_RELATIVE = 'send-mouse-relative',
|
||||
SEND_MOUSE_ABSOLUTE = 'send-mouse-absolute',
|
||||
SEND_MOUSE = 'send-mouse',
|
||||
|
||||
UPDATE_AVAILABLE = 'update-available',
|
||||
UPDATE_NOT_AVAILABLE = 'update-not-available',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CmdEvent, CmdPacket, InfoPacket } from './proto'
|
||||
import { SerialPort } from './serial-port'
|
||||
import { intToByte, intToLittleEndianList } from './utils'
|
||||
|
||||
export class Device {
|
||||
addr: number
|
||||
@@ -20,36 +19,16 @@ export class Device {
|
||||
return new InfoPacket(rspPacket.DATA)
|
||||
}
|
||||
|
||||
async sendKeyboardData(data: number[]): Promise<void> {
|
||||
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 sendMouseRelativeData(key: number, x: number, y: number, scroll: number): Promise<void> {
|
||||
const xByte = intToByte(x)
|
||||
const yByte = intToByte(y)
|
||||
async sendMouseData(report: number[]): Promise<void> {
|
||||
if (report.length === 0) return
|
||||
|
||||
const data = [0x01, key, xByte, yByte, scroll]
|
||||
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_MS_REL_DATA, data).encode()
|
||||
await this.serialPort.write(cmdData)
|
||||
}
|
||||
|
||||
async sendMouseAbsoluteData(
|
||||
key: number,
|
||||
width: number,
|
||||
height: number,
|
||||
x: number,
|
||||
y: number,
|
||||
scroll: number
|
||||
): Promise<void> {
|
||||
const xAbs = width === 0 ? 0 : Math.floor((x * 4096) / width)
|
||||
const xLittle = intToLittleEndianList(xAbs)
|
||||
|
||||
const yAbs = width === 0 ? 0 : Math.floor((y * 4096) / height)
|
||||
const yLittle = intToLittleEndianList(yAbs)
|
||||
|
||||
const data = [0x02, key, ...xLittle, ...yLittle, scroll]
|
||||
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_MS_ABS_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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getBit } from './utils'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export function getBit(number: number, bitPosition: number): number {
|
||||
return (number >> bitPosition) & 1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -9,8 +9,7 @@ export function registerSerialPort(): void {
|
||||
ipcMain.handle(IpcEvents.OPEN_SERIAL_PORT, openSerialPort)
|
||||
ipcMain.handle(IpcEvents.CLOSE_SERIAL_PORT, closeSerialPort)
|
||||
ipcMain.handle(IpcEvents.SEND_KEYBOARD, sendKeyboard)
|
||||
ipcMain.handle(IpcEvents.SEND_MOUSE_ABSOLUTE, sendMouseAbsolute)
|
||||
ipcMain.handle(IpcEvents.SEND_MOUSE_RELATIVE, sendMouseRelative)
|
||||
ipcMain.handle(IpcEvents.SEND_MOUSE, sendMouse)
|
||||
}
|
||||
|
||||
async function getSerialPorts(): Promise<string[]> {
|
||||
@@ -58,32 +57,10 @@ async function sendKeyboard(_: IpcMainInvokeEvent, report: number[]): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMouseRelative(
|
||||
_: IpcMainInvokeEvent,
|
||||
key: number,
|
||||
x: number,
|
||||
y: number,
|
||||
scroll: number
|
||||
): Promise<void> {
|
||||
async function sendMouse(_: IpcMainInvokeEvent, report: number[]): Promise<void> {
|
||||
try {
|
||||
await device.sendMouseRelativeData(key, x, y, scroll)
|
||||
await device.sendMouseData(report)
|
||||
} catch (error) {
|
||||
console.error('Error sending mouse relative data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMouseAbsolute(
|
||||
_: IpcMainInvokeEvent,
|
||||
key: number,
|
||||
width: number,
|
||||
height: number,
|
||||
x: number,
|
||||
y: number,
|
||||
scroll: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await device.sendMouseAbsoluteData(key, width, height, x, y, scroll)
|
||||
} catch (error) {
|
||||
console.error('Error sending mouse absolute data:', error)
|
||||
console.error('Error sending mouse data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.sipeed.usbkvm')
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((_, permission, callback) => {
|
||||
const allowedPermissions = ['media', 'clipboard-read']
|
||||
const allowedPermissions = ['media', 'clipboard-read', 'pointerLock']
|
||||
callback(allowedPermissions.includes(permission))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,128 +1,43 @@
|
||||
import { ReactElement, useEffect, useRef } from 'react'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
import { IpcEvents } from '@common/ipc-events'
|
||||
import { resolutionAtom } from '@renderer/jotai/device'
|
||||
import { scrollDirectionAtom, scrollIntervalAtom } from '@renderer/jotai/mouse'
|
||||
import { Key } from '@renderer/libs/mouse'
|
||||
import { MouseAbsoluteRelative } from '@renderer/libs/mouse'
|
||||
import { mouseJiggler } from '@renderer/libs/mouse-jiggler'
|
||||
|
||||
import { createInitialTouchState, createTouchHandlers } from './touchpad'
|
||||
import { MouseAbsoluteEvent } from './types'
|
||||
|
||||
export const Absolute = (): ReactElement => {
|
||||
const resolution = useAtomValue(resolutionAtom)
|
||||
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())
|
||||
|
||||
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)
|
||||
function getCoordinate(event: { clientX: number; clientY: number }): { x: number; y: number } {
|
||||
const rect = screen.getBoundingClientRect()
|
||||
|
||||
// press button
|
||||
async function handleMouseDown(event: MouseEvent): Promise<void> {
|
||||
disableEvent(event)
|
||||
const clientX = event.clientX
|
||||
const clientY = event.clientY
|
||||
|
||||
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: MouseEvent): Promise<void> {
|
||||
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: MouseEvent): Promise<void> {
|
||||
disableEvent(event)
|
||||
await send(event)
|
||||
|
||||
mouseJiggler.moveEventCallback()
|
||||
}
|
||||
|
||||
// mouse scroll
|
||||
async function handleWheel(event: WheelEvent): Promise<void> {
|
||||
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)
|
||||
|
||||
lastScrollTimeRef.current = currentTime
|
||||
}
|
||||
|
||||
async function send(event: MouseEvent, scroll: number = 0): Promise<void> {
|
||||
const { x, y } = getCorrectedCoords(event.clientX, event.clientY)
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
IpcEvents.SEND_MOUSE_ABSOLUTE,
|
||||
keyRef.current.encode(),
|
||||
1,
|
||||
1,
|
||||
x,
|
||||
y,
|
||||
scroll
|
||||
)
|
||||
}
|
||||
|
||||
function getCorrectedCoords(clientX: number, clientY: number) {
|
||||
if (!canvas) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const videoElement = canvas as HTMLVideoElement
|
||||
|
||||
if (!videoElement.videoWidth || !videoElement.videoHeight) {
|
||||
if (!screen.videoWidth || !screen.videoHeight) {
|
||||
const x = (clientX - rect.left) / rect.width
|
||||
const y = (clientY - rect.top) / rect.height
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
const videoRatio = videoElement.videoWidth / videoElement.videoHeight
|
||||
const videoRatio = screen.videoWidth / screen.videoHeight
|
||||
const elementRatio = rect.width / rect.height
|
||||
|
||||
let renderedWidth = rect.width
|
||||
@@ -140,27 +55,124 @@ export const Absolute = (): ReactElement => {
|
||||
|
||||
const x = (clientX - rect.left - offsetX) / renderedWidth
|
||||
const y = (clientY - rect.top - offsetY) / renderedHeight
|
||||
|
||||
const finalX = Math.max(0, Math.min(1, x))
|
||||
const finalY = Math.max(0, Math.min(1, y))
|
||||
|
||||
return { x: finalX, y: finalY }
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
canvas.removeEventListener('mousemove', handleMouseMove)
|
||||
canvas.removeEventListener('mousedown', handleMouseDown)
|
||||
canvas.removeEventListener('mouseup', handleMouseUp)
|
||||
canvas.removeEventListener('wheel', handleWheel)
|
||||
canvas.removeEventListener('click', disableEvent)
|
||||
canvas.removeEventListener('contextmenu', disableEvent)
|
||||
// Disable default events
|
||||
function disableEvent(event: Event): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}, [resolution, scrollDirection, scrollInterval])
|
||||
|
||||
function disableEvent(event: MouseEvent): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
// Mouse event handler
|
||||
function handleMouseEvent(event: MouseAbsoluteEvent): 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
|
||||
}
|
||||
|
||||
window.electron.ipcRenderer.invoke(IpcEvents.SEND_MOUSE, [0x02, ...report])
|
||||
|
||||
mouseJiggler.moveEventCallback()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Create touch handlers
|
||||
const touchState = touchStateRef.current
|
||||
const touchHandlers = createTouchHandlers(touchState, {
|
||||
scrollDirection,
|
||||
scrollInterval,
|
||||
getCoordinate,
|
||||
handleMouseEvent,
|
||||
disableEvent
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
return () => {
|
||||
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)
|
||||
|
||||
touchHandlers.cleanup()
|
||||
}
|
||||
}, [isBigScreen, scrollDirection, scrollInterval])
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
@@ -1,140 +1,160 @@
|
||||
import { ReactElement, useEffect, useRef } from 'react'
|
||||
import { message } from 'antd'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { IpcEvents } from '@common/ipc-events'
|
||||
import { resolutionAtom } from '@renderer/jotai/device'
|
||||
import { scrollDirectionAtom, scrollIntervalAtom } from '@renderer/jotai/mouse'
|
||||
import { Key } from '@renderer/libs/mouse'
|
||||
import { MouseReportRelative } from '@renderer/libs/mouse'
|
||||
import { mouseJiggler } from '@renderer/libs/mouse-jiggler'
|
||||
|
||||
import type { MouseRelativeEvent } from './types'
|
||||
|
||||
export const Relative = (): ReactElement => {
|
||||
const resolution = useAtomValue(resolutionAtom)
|
||||
const { t } = useTranslation()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = document.getElementById('video')
|
||||
if (!canvas) return
|
||||
const screen = document.getElementById('video')
|
||||
if (!screen) return
|
||||
|
||||
showMessage()
|
||||
|
||||
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)
|
||||
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 handlePointerLockChange(): void {
|
||||
isLockedRef.current = document.pointerLockElement === canvas
|
||||
}
|
||||
|
||||
// Click to request pointer lock
|
||||
function handleClick(event: MouseEvent): void {
|
||||
disableEvent(event)
|
||||
|
||||
if (!isLockedRef.current) {
|
||||
canvas!.requestPointerLock()
|
||||
screen?.requestPointerLock()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMouseDown(event: MouseEvent): Promise<void> {
|
||||
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)
|
||||
// Mouse down event
|
||||
function handleMouseDown(e: MouseEvent): void {
|
||||
disableEvent(e)
|
||||
handleMouseEvent({ type: 'mousedown', button: e.button })
|
||||
}
|
||||
|
||||
async function handleMouseUp(event: MouseEvent): Promise<void> {
|
||||
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 up event
|
||||
function handleMouseUp(e: MouseEvent): void {
|
||||
disableEvent(e)
|
||||
handleMouseEvent({ type: 'mouseup', button: e.button })
|
||||
}
|
||||
|
||||
async function handleMouseMove(event: MouseEvent): Promise<void> {
|
||||
disableEvent(event)
|
||||
// Mouse move event
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
disableEvent(e)
|
||||
|
||||
const x = event.movementX || 0
|
||||
const y = event.movementY || 0
|
||||
const x = e.movementX || 0
|
||||
const y = e.movementY || 0
|
||||
if (x === 0 && y === 0) return
|
||||
|
||||
await send(Math.abs(x) < 10 ? x * 2 : x, Math.abs(y) < 10 ? y * 2 : y, 0)
|
||||
const deltaX = Math.abs(x * window.devicePixelRatio) < 10 ? x * 2 : x
|
||||
const deltaY = Math.abs(y * window.devicePixelRatio) < 10 ? y * 2 : y
|
||||
|
||||
mouseJiggler.moveEventCallback()
|
||||
handleMouseEvent({ type: 'move', deltaX, deltaY })
|
||||
}
|
||||
|
||||
async function handleWheel(event: WheelEvent): Promise<void> {
|
||||
disableEvent(event)
|
||||
// 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 delta = Math.floor(event.deltaY)
|
||||
if (delta === 0) return
|
||||
|
||||
await send(0, 0, delta > 0 ? -1 * scrollDirection : scrollDirection)
|
||||
|
||||
const deltaY = (e.deltaY > 0 ? 1 : -1) * scrollDirection
|
||||
handleMouseEvent({ type: 'wheel', deltaY })
|
||||
lastScrollTimeRef.current = currentTime
|
||||
}
|
||||
|
||||
async function send(x: number, y: number, scroll: number): Promise<void> {
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
IpcEvents.SEND_MOUSE_RELATIVE,
|
||||
keyRef.current.encode(),
|
||||
x,
|
||||
y,
|
||||
scroll
|
||||
)
|
||||
// Pointer lock state change
|
||||
function handlePointerLockChange(): void {
|
||||
isLockedRef.current = document.pointerLockElement === screen
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
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])
|
||||
// Exit pointer lock when component unmounts
|
||||
if (document.pointerLockElement === screen) {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
|
||||
function disableEvent(event: MouseEvent): void {
|
||||
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
|
||||
function handleMouseEvent(event: MouseRelativeEvent): 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
|
||||
}
|
||||
|
||||
window.electron.ipcRenderer.invoke(IpcEvents.SEND_MOUSE, [0x01, ...report])
|
||||
|
||||
mouseJiggler.moveEventCallback()
|
||||
}
|
||||
|
||||
function showMessage(): void {
|
||||
messageApi.open({
|
||||
key: 'requestPointer',
|
||||
type: 'info',
|
||||
content: t('mouse.requestPointer'),
|
||||
duration: 3,
|
||||
style: {
|
||||
marginTop: '40vh'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function disableEvent(event: Event): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
return <></>
|
||||
return <>{contextHolder}</>
|
||||
}
|
||||
|
||||
204
desktop/src/renderer/src/components/mouse/touchpad.ts
Normal file
204
desktop/src/renderer/src/components/mouse/touchpad.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
33
desktop/src/renderer/src/components/mouse/types.ts
Normal file
33
desktop/src/renderer/src/components/mouse/types.ts
Normal 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
|
||||
@@ -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)
|
||||
export const scrollIntervalAtom = atom(0)
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { IpcEvents } from '@common/ipc-events'
|
||||
import { Key } from '@renderer/libs/mouse'
|
||||
import { MouseReportRelative } from '@renderer/libs/mouse'
|
||||
|
||||
const MOUSE_JIGGLER_INTERVAL = 15_000
|
||||
const EMPTY_KEY: Key = new Key(false, false, false)
|
||||
|
||||
class MouseJiggler {
|
||||
private lastMoveTime: number
|
||||
private timer: NodeJS.Timeout | 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,20 +44,13 @@ class MouseJiggler {
|
||||
}
|
||||
|
||||
async sendJiggle(): Promise<void> {
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
IpcEvents.SEND_MOUSE_RELATIVE,
|
||||
EMPTY_KEY.encode(),
|
||||
10,
|
||||
10,
|
||||
0
|
||||
)
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
IpcEvents.SEND_MOUSE_RELATIVE,
|
||||
EMPTY_KEY.encode(),
|
||||
-10,
|
||||
-10,
|
||||
0
|
||||
)
|
||||
// Build reports directly using the report builder
|
||||
const report1 = this.mouseReport.buildReport(10, 10, 0)
|
||||
const report2 = this.mouseReport.buildReport(-10, -10, 0)
|
||||
|
||||
await window.electron.ipcRenderer.invoke(IpcEvents.SEND_MOUSE, [0x01, report1])
|
||||
await window.electron.ipcRenderer.invoke(IpcEvents.SEND_MOUSE, [0x01, report2])
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseJiggler = new MouseJiggler()
|
||||
|
||||
@@ -1,27 +1,134 @@
|
||||
export class Key {
|
||||
left: boolean
|
||||
right: boolean
|
||||
mid: boolean
|
||||
// Maximum absolute coordinate value
|
||||
const MAX_ABS_COORD = 4096
|
||||
|
||||
constructor(left: boolean = false, right: boolean = false, mid: boolean = false) {
|
||||
this.left = left
|
||||
this.right = right
|
||||
this.mid = mid
|
||||
}
|
||||
// Button bit positions
|
||||
const MouseButtons = {
|
||||
Left: 1 << 0,
|
||||
Right: 1 << 1,
|
||||
Middle: 1 << 2,
|
||||
Back: 1 << 3,
|
||||
Forward: 1 << 4
|
||||
} as const
|
||||
|
||||
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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
function setBit(number: number, bitPosition: number, value: boolean): number {
|
||||
if (value) {
|
||||
return number | (1 << bitPosition)
|
||||
} else {
|
||||
return number & ~(1 << bitPosition)
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user