From d52e7d04d12f14b718b22182c5ca765bf006631b Mon Sep 17 00:00:00 2001 From: Aveline <352441+ym@users.noreply.github.com> Date: Wed, 19 Mar 2025 11:47:15 +0100 Subject: [PATCH] feat: relative mouse (#246) --- jsonrpc.go | 1 + ui/src/components/InfoBar.tsx | 14 +++- ui/src/components/WebRTCVideo.tsx | 75 ++++++++++++++++---- ui/src/hooks/stores.ts | 32 +++++---- ui/src/routes/devices.$id.settings.mouse.tsx | 65 ++++++++--------- usb.go | 7 +- 6 files changed, 133 insertions(+), 61 deletions(-) diff --git a/jsonrpc.go b/jsonrpc.go index 298a810..64935e1 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -799,6 +799,7 @@ var rpcHandlers = map[string]RPCHandler{ "getCloudState": {Func: rpcGetCloudState}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, + "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "getVideoState": {Func: rpcGetVideoState}, "getUSBState": {Func: rpcGetUSBState}, diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index be94043..7c002f1 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -14,6 +14,7 @@ export default function InfoBar() { const activeModifiers = useHidStore(state => state.activeModifiers); const mouseX = useMouseStore(state => state.mouseX); const mouseY = useMouseStore(state => state.mouseY); + const mouseMove = useMouseStore(state => state.mouseMove); const videoClientSize = useVideoStore( state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, @@ -62,7 +63,7 @@ export default function InfoBar() { ) : null} - {settings.debugMode ? ( + {(settings.debugMode && settings.mouseMode == "absolute") ? (
Pointer: @@ -71,6 +72,17 @@ export default function InfoBar() {
) : null} + {(settings.debugMode && settings.mouseMode == "relative") ? ( +
+ Last Move: + + {mouseMove ? + `${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` : + "N/A"} + +
+ ) : null} + {settings.debugMode && (
USB State: diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 1587d29..29c72d1 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -29,6 +29,7 @@ export default function WebRTCVideo() { const settings = useSettingsStore(); const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); const setMousePosition = useMouseStore(state => state.setMousePosition); + const setMouseMove = useMouseStore(state => state.setMouseMove); const { setClientSize: setVideoClientSize, setSize: setVideoSize, @@ -93,19 +94,44 @@ export default function WebRTCVideo() { ); // Mouse-related - const sendMouseMovement = useCallback( + const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos; + const sendRelMouseMovement = useCallback( (x: number, y: number, buttons: number) => { - send("absMouseReport", { x, y, buttons }); + if (settings.mouseMode !== "relative") return; + // if we ignore the event, double-click will not work + // if (x === 0 && y === 0 && buttons === 0) return; + send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons }); + setMouseMove({ x, y, buttons }); + }, + [send, setMouseMove, settings.mouseMode], + ); + const relMouseMoveHandler = useCallback( + (e: MouseEvent) => { + if (settings.mouseMode !== "relative") return; + + // Send mouse movement + const { buttons } = e; + sendRelMouseMovement(e.movementX, e.movementY, buttons); + }, + [sendRelMouseMovement, settings.mouseMode], + ); + + const sendAbsMouseMovement = useCallback( + (x: number, y: number, buttons: number) => { + if (settings.mouseMode !== "absolute") return; + send("absMouseReport", { x, y, buttons }); // We set that for the debug info bar setMousePosition(x, y); }, - [send, setMousePosition], + [send, setMousePosition, settings.mouseMode], ); - const mouseMoveHandler = useCallback( + const absMouseMoveHandler = useCallback( (e: MouseEvent) => { if (!videoClientWidth || !videoClientHeight) return; + if (settings.mouseMode !== "absolute") return; + // Get the aspect ratios of the video element and the video stream const videoElementAspectRatio = videoClientWidth / videoClientHeight; const videoStreamAspectRatio = videoWidth / videoHeight; @@ -140,9 +166,9 @@ export default function WebRTCVideo() { // Send mouse movement const { buttons } = e; - sendMouseMovement(x, y, buttons); + sendAbsMouseMovement(x, y, buttons); }, - [sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight], + [sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode], ); const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity); @@ -193,8 +219,8 @@ export default function WebRTCVideo() { ); const resetMousePosition = useCallback(() => { - sendMouseMovement(0, 0, 0); - }, [sendMouseMovement]); + sendAbsMouseMovement(0, 0, 0); + }, [sendAbsMouseMovement]); // Keyboard-related const handleModifierKeys = useCallback( @@ -371,9 +397,9 @@ export default function WebRTCVideo() { const abortController = new AbortController(); const signal = abortController.signal; - videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal, @@ -395,7 +421,7 @@ export default function WebRTCVideo() { }; }, [ - mouseMoveHandler, + absMouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler, @@ -403,6 +429,31 @@ export default function WebRTCVideo() { ], ); + useEffect( + function setupRelativeMouseEventListeners() { + if (settings.mouseMode !== "relative") return; + + const abortController = new AbortController(); + const signal = abortController.signal; + + // bind to body to capture all mouse events + const body = document.querySelector("body"); + if (!body) return; + + body.addEventListener("mousemove", relMouseMoveHandler, { signal }); + body.addEventListener("pointerdown", relMouseMoveHandler, { signal }); + body.addEventListener("pointerup", relMouseMoveHandler, { signal }); + + return () => { + abortController.abort(); + + body.removeEventListener("mousemove", relMouseMoveHandler); + body.removeEventListener("pointerdown", relMouseMoveHandler); + body.removeEventListener("pointerup", relMouseMoveHandler); + }; + }, [settings.mouseMode, relMouseMoveHandler], + ) + useEffect( function updateVideoStream() { if (!mediaStream) return; diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index ac8ad7d..f30c28c 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -197,15 +197,23 @@ export const useRTCStore = create(set => ({ setTerminalChannel: channel => set({ terminalChannel: channel }), })); +interface MouseMove { + x: number; + y: number; + buttons: number; +} interface MouseState { mouseX: number; mouseY: number; + mouseMove?: MouseMove; + setMouseMove: (move?: MouseMove) => void; setMousePosition: (x: number, y: number) => void; } export const useMouseStore = create(set => ({ mouseX: 0, mouseY: 0, + setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), })); @@ -543,12 +551,12 @@ export interface UpdateState { setOtaState: (state: UpdateState["otaState"]) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; modalView: - | "loading" - | "updating" - | "upToDate" - | "updateAvailable" - | "updateCompleted" - | "error"; + | "loading" + | "updating" + | "upToDate" + | "updateAvailable" + | "updateCompleted" + | "error"; setModalView: (view: UpdateState["modalView"]) => void; setUpdateErrorMessage: (errorMessage: string) => void; updateErrorMessage: string | null; @@ -612,12 +620,12 @@ export const useUsbConfigModalStore = create(set => ({ interface LocalAuthModalState { modalView: - | "createPassword" - | "deletePassword" - | "updatePassword" - | "creationSuccess" - | "deleteSuccess" - | "updateSuccess"; + | "createPassword" + | "deletePassword" + | "updatePassword" + | "creationSuccess" + | "deleteSuccess" + | "updateSuccess"; setModalView: (view: LocalAuthModalState["modalView"]) => void; } diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index c8c351a..1d3a6cd 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -1,23 +1,27 @@ -import { SettingsPageHeader } from "@components/SettingsPageheader"; -import { SettingsItem } from "./devices.$id.settings"; -import { Checkbox } from "@/components/Checkbox"; -import { GridCard } from "@/components/Card"; +import MouseIcon from "@/assets/mouse-icon.svg"; import PointingFinger from "@/assets/pointing-finger.svg"; -import { CheckCircleIcon } from "@heroicons/react/16/solid"; +import { GridCard } from "@/components/Card"; +import { Checkbox } from "@/components/Checkbox"; import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores"; -import notifications from "@/notifications"; -import { useCallback, useEffect, useState } from "react"; import { useJsonRpc } from "@/hooks/useJsonRpc"; -import { cx } from "../cva.config"; +import notifications from "@/notifications"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { CheckCircleIcon } from "@heroicons/react/16/solid"; +import { useCallback, useEffect, useState } from "react"; +import { FeatureFlag } from "../components/FeatureFlag"; import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { useFeatureFlag } from "../hooks/useFeatureFlag"; -import { FeatureFlag } from "../components/FeatureFlag"; +import { SettingsItem } from "./devices.$id.settings"; type ScrollSensitivity = "low" | "default" | "high"; export default function SettingsKeyboardMouseRoute() { const hideCursor = useSettingsStore(state => state.isCursorHidden); const setHideCursor = useSettingsStore(state => state.setCursorVisibility); + + const mouseMode = useSettingsStore(state => state.mouseMode); + const setMouseMode = useSettingsStore(state => state.setMouseMode); + const scrollSensitivity = useDeviceSettingsStore(state => state.scrollSensitivity); const setScrollSensitivity = useDeviceSettingsStore( state => state.setScrollSensitivity, @@ -122,19 +126,19 @@ export default function SettingsKeyboardMouseRoute() {
-
+