From 9e6d280826e0211150f47351e20cc35704e16e81 Mon Sep 17 00:00:00 2001 From: watermeko Date: Fri, 21 Aug 2026 08:28:07 +0000 Subject: [PATCH] feat(web): add configurable mouse input regions Add device-level input-region APIs for reading, validating, and atomically persisting absolute mouse calibration settings, including automatic, manual, and disabled modes. Introduce a shared screen viewport that keeps the rendered media geometry and configured input region aligned across MJPEG, direct H.264, and WebRTC streams. Add automatic black-border detection and a manual selection overlay with dragging, resizing, magnified preview, keyboard locking, and cancellation support. Provide original-resolution presets so manual calibration can be recalculated for common aspect ratios, expose the controls from the desktop mouse menu, close menus before entering selection mode, and preserve active file transfers when the download menu closes. Add localized control-region labels and messages for all supported locales, and update direct-frame rendering so media dimensions are reported after the canvas has been drawn. --- server/proto/vm.go | 38 + server/router/vm.go | 4 + server/service/vm/input_region.go | 283 +++++++ web/src/api/vm.ts | 46 ++ web/src/components/menu-item.tsx | 24 +- web/src/i18n/locales/ca.ts | 30 + web/src/i18n/locales/cz.ts | 29 + web/src/i18n/locales/da.ts | 30 + web/src/i18n/locales/de.ts | 31 + web/src/i18n/locales/en.ts | 29 + web/src/i18n/locales/es.ts | 30 + web/src/i18n/locales/fr.ts | 30 + web/src/i18n/locales/hu.ts | 30 + web/src/i18n/locales/id.ts | 30 + web/src/i18n/locales/it.ts | 30 + web/src/i18n/locales/ja.ts | 30 + web/src/i18n/locales/ko.ts | 29 + web/src/i18n/locales/nb.ts | 30 + web/src/i18n/locales/nl.ts | 31 + web/src/i18n/locales/pl.ts | 30 + web/src/i18n/locales/pt_br.ts | 30 + web/src/i18n/locales/ru.ts | 30 + web/src/i18n/locales/se.ts | 29 + web/src/i18n/locales/th.ts | 29 + web/src/i18n/locales/tr.ts | 30 + web/src/i18n/locales/uk.ts | 30 + web/src/i18n/locales/vi.ts | 30 + web/src/i18n/locales/zh.ts | 29 + web/src/i18n/locales/zh_tw.ts | 29 + web/src/jotai/screen.ts | 13 +- web/src/jotai/settings.ts | 3 + web/src/pages/desktop/index.tsx | 133 +++- web/src/pages/desktop/menu/download.tsx | 3 +- web/src/pages/desktop/menu/index.tsx | 15 +- web/src/pages/desktop/menu/mouse/index.tsx | 2 + .../menu/screen/original-resolution.tsx | 293 +++++++ web/src/pages/desktop/mouse/absolute.tsx | 225 ++---- web/src/pages/desktop/screen/auto-region.tsx | 75 ++ web/src/pages/desktop/screen/direct.worker.ts | 4 +- web/src/pages/desktop/screen/geometry.ts | 278 +++++++ web/src/pages/desktop/screen/h264-direct.tsx | 24 +- web/src/pages/desktop/screen/h264-webrtc.tsx | 27 +- .../desktop/screen/input-region-overlay.tsx | 746 ++++++++++++++++++ .../pages/desktop/screen/manual-region.tsx | 63 ++ web/src/pages/desktop/screen/mjpeg.tsx | 26 +- web/src/pages/desktop/screen/viewport.tsx | 99 +++ web/src/types/index.ts | 19 + 47 files changed, 2905 insertions(+), 253 deletions(-) create mode 100644 server/service/vm/input_region.go create mode 100644 web/src/pages/desktop/menu/screen/original-resolution.tsx create mode 100644 web/src/pages/desktop/screen/auto-region.tsx create mode 100644 web/src/pages/desktop/screen/geometry.ts create mode 100644 web/src/pages/desktop/screen/input-region-overlay.tsx create mode 100644 web/src/pages/desktop/screen/manual-region.tsx create mode 100644 web/src/pages/desktop/screen/viewport.tsx diff --git a/server/proto/vm.go b/server/proto/vm.go index aa4c2d5..3467e13 100644 --- a/server/proto/vm.go +++ b/server/proto/vm.go @@ -152,3 +152,41 @@ type GetWebTitleRsp struct { type SetTlsReq struct { Enabled bool `validate:"omitempty"` } + +type InputRegion struct { + Mode string `json:"mode"` + FrameWidth int `json:"frameWidth"` + FrameHeight int `json:"frameHeight"` + Left int `json:"left"` + Top int `json:"top"` + Width int `json:"width"` + Height int `json:"height"` + Resolutions []OriginalResolution `json:"resolutions,omitempty"` + SelectedResolution string `json:"selectedResolution"` +} + +type OriginalResolution struct { + Width int `json:"width"` + Height int `json:"height"` +} + +type SetInputRegionReq struct { + Mode string `json:"mode"` + FrameWidth *int `json:"frameWidth,omitempty"` + FrameHeight *int `json:"frameHeight,omitempty"` + Left *int `json:"left,omitempty"` + Top *int `json:"top,omitempty"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` + Resolutions *[]OriginalResolution `json:"resolutions,omitempty"` + SelectedResolution *string `json:"selectedResolution,omitempty"` +} + +type GetInputRegionRsp struct { + InputRegion +} + +type GetInputResolutionRsp struct { + Width int `json:"width"` + Height int `json:"height"` +} diff --git a/server/router/vm.go b/server/router/vm.go index 4b4b58a..2cf555f 100644 --- a/server/router/vm.go +++ b/server/router/vm.go @@ -24,6 +24,10 @@ func vmRouter(r *gin.Engine) { api.GET("/vm/gpio", service.GetGpio) // get gpio api.POST("/vm/screen", service.SetScreen) // update screen + api.GET("/vm/input-region", service.GetInputRegion) + api.POST("/vm/input-region", service.SetInputRegion) + api.GET("/vm/input-resolution", service.GetInputResolution) + admin.GET("/vm/terminal", service.Terminal) // web terminal admin.GET("/vm/script", service.GetScripts) // get script diff --git a/server/service/vm/input_region.go b/server/service/vm/input_region.go new file mode 100644 index 0000000..aa87c3b --- /dev/null +++ b/server/service/vm/input_region.go @@ -0,0 +1,283 @@ +package vm + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "NanoKVM-Server/proto" + + "github.com/gin-gonic/gin" +) + +var ( + inputRegionFile = "/etc/kvm/input-region.json" + inputRegionMu sync.RWMutex +) + +func (s *Service) GetInputResolution(c *gin.Context) { + var rsp proto.Response + width, widthErr := readPositiveInt("/kvmapp/kvm/width") + height, heightErr := readPositiveInt("/kvmapp/kvm/height") + if widthErr != nil || heightErr != nil { + rsp.ErrRsp(c, -1, "failed to read input resolution") + return + } + rsp.OkRspWithData(c, &proto.GetInputResolutionRsp{Width: width, Height: height}) +} + +func readPositiveInt(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + value, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || value <= 0 { + return 0, errors.New("invalid positive integer") + } + return value, nil +} + +func (s *Service) GetInputRegion(c *gin.Context) { + var rsp proto.Response + + inputRegionMu.RLock() + region, err := readInputRegion(inputRegionFile) + inputRegionMu.RUnlock() + if errors.Is(err, os.ErrNotExist) { + rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}}) + return + } + if err != nil { + rsp.ErrRsp(c, -1, "failed to read input region") + return + } + if region == nil { + rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}}) + return + } + + rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: *region}) +} + +func (s *Service) SetInputRegion(c *gin.Context) { + var req proto.SetInputRegionReq + var rsp proto.Response + + if err := c.ShouldBindJSON(&req); err != nil { + rsp.ErrRsp(c, -1, "invalid arguments") + return + } + + inputRegionMu.Lock() + defer inputRegionMu.Unlock() + + if req.Mode == "off" || req.Mode == "auto" || + (req.Mode == "manual" && req.FrameWidth == nil && req.FrameHeight == nil && + req.Left == nil && req.Top == nil && req.Width == nil && req.Height == nil) { + region, err := readInputRegion(inputRegionFile) + if err != nil && !errors.Is(err, os.ErrNotExist) { + rsp.ErrRsp(c, -2, "failed to read input region") + return + } + if region == nil { + region = &proto.InputRegion{} + } + region.Mode = req.Mode + if req.Resolutions != nil { + if err := validateOriginalResolutions(*req.Resolutions); err != nil { + rsp.ErrRsp(c, -1, "invalid original resolutions") + return + } + region.Resolutions = *req.Resolutions + } + if req.SelectedResolution != nil { + region.SelectedResolution = *req.SelectedResolution + } + if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil { + rsp.ErrRsp(c, -1, "invalid selected resolution") + return + } + if err := writeInputRegion(inputRegionFile, *region); err != nil { + rsp.ErrRsp(c, -2, "failed to save input region mode") + return + } + rsp.OkRsp(c) + return + } + if req.Mode != "manual" { + rsp.ErrRsp(c, -1, "invalid input region mode") + return + } + region, err := inputRegionFromRequest(req) + if err != nil { + rsp.ErrRsp(c, -1, "invalid input region") + return + } + previous, readErr := readInputRegion(inputRegionFile) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + rsp.ErrRsp(c, -2, "failed to read input region") + return + } + if previous != nil { + region.Resolutions = previous.Resolutions + region.SelectedResolution = previous.SelectedResolution + } + if req.Resolutions != nil { + if err := validateOriginalResolutions(*req.Resolutions); err != nil { + rsp.ErrRsp(c, -1, "invalid original resolutions") + return + } + region.Resolutions = *req.Resolutions + } + if req.SelectedResolution != nil { + region.SelectedResolution = *req.SelectedResolution + } + if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil { + rsp.ErrRsp(c, -1, "invalid selected resolution") + return + } + if err := writeInputRegion(inputRegionFile, region); err != nil { + rsp.ErrRsp(c, -2, "failed to save input region") + return + } + + rsp.OkRsp(c) +} + +func validateOriginalResolutions(resolutions []proto.OriginalResolution) error { + seen := make(map[proto.OriginalResolution]struct{}, len(resolutions)) + for _, resolution := range resolutions { + if resolution.Width <= 0 || resolution.Height <= 0 { + return errors.New("resolution dimensions must be positive") + } + if _, ok := seen[resolution]; ok { + return errors.New("duplicate resolution") + } + seen[resolution] = struct{}{} + } + return nil +} + +func validateSelectedResolution(selected string, resolutions []proto.OriginalResolution) error { + if selected == "" { + return nil + } + for _, resolution := range resolutions { + if selected == resolutionKey(resolution) { + return nil + } + } + return errors.New("selected resolution not found") +} + +func resolutionKey(resolution proto.OriginalResolution) string { + return fmt.Sprintf("%dx%d", resolution.Width, resolution.Height) +} + +func inputRegionFromRequest(req proto.SetInputRegionReq) (proto.InputRegion, error) { + if req.FrameWidth == nil || req.FrameHeight == nil || req.Left == nil || + req.Top == nil || req.Width == nil || req.Height == nil { + return proto.InputRegion{}, errors.New("input region must be complete") + } + + region := proto.InputRegion{ + Mode: "manual", + FrameWidth: *req.FrameWidth, + FrameHeight: *req.FrameHeight, + Left: *req.Left, + Top: *req.Top, + Width: *req.Width, + Height: *req.Height, + } + return region, validateInputRegion(region) +} + +func validateInputRegion(region proto.InputRegion) error { + if region.Mode == "off" || region.Mode == "auto" { + return nil + } + if region.Mode == "manual" && region.FrameWidth == 0 && region.FrameHeight == 0 { + return nil + } + if region.Mode != "manual" { + return errors.New("invalid input region mode") + } + if region.FrameWidth <= 0 || region.FrameHeight <= 0 { + return errors.New("frame dimensions must be positive") + } + if region.Left < 0 || region.Top < 0 || region.Width <= 0 || region.Height <= 0 { + return errors.New("region dimensions must be positive and offsets non-negative") + } + if region.Left > region.FrameWidth-region.Width || region.Top > region.FrameHeight-region.Height { + return errors.New("region must be inside frame") + } + return nil +} + +func readInputRegion(path string) (*proto.InputRegion, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var region *proto.InputRegion + if err := json.Unmarshal(data, ®ion); err != nil { + return nil, nil + } + if region == nil { + return nil, nil + } + if region.Mode == "" { + region.Mode = "manual" + } + if err := validateInputRegion(*region); err != nil { + return nil, nil + } + return region, nil +} + +func writeInputRegion(path string, region proto.InputRegion) error { + data, err := json.Marshal(region) + if err != nil { + return err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + tmp, err := os.CreateTemp(dir, ".input-region-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0o644); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +func removeInputRegion(path string) error { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} diff --git a/web/src/api/vm.ts b/web/src/api/vm.ts index b2a1d39..47af831 100644 --- a/web/src/api/vm.ts +++ b/web/src/api/vm.ts @@ -1,3 +1,4 @@ +import { ControlRegionMode, InputRegion, OriginalResolution } from '@/types'; import { http } from '@/lib/http.ts'; // get NanoKVM information @@ -33,6 +34,51 @@ export function updateScreen(type: string, value: number) { return http.post('/api/vm/screen', data); } +// get the device-level absolute mouse input region +export function getInputRegion() { + return http.get('/api/vm/input-region'); +} + +export function getInputResolution() { + return http.get('/api/vm/input-resolution'); +} + +// save the device-level absolute mouse input region +export function setInputRegion(region: InputRegion) { + return http.post('/api/vm/input-region', { mode: 'manual', ...region }); +} + +export function setInputRegionConfig(region: InputRegion, selectedResolution: string) { + return http.post('/api/vm/input-region', { + mode: 'manual', + ...region, + selectedResolution + }); +} + +export function setControlRegionMode(mode: ControlRegionMode) { + return http.post('/api/vm/input-region', { mode }); +} + +export function setOriginalResolutions(mode: ControlRegionMode, resolutions: OriginalResolution[]) { + return http.post('/api/vm/input-region', { mode, resolutions }); +} + +export function setOriginalResolutionConfig( + resolutions: OriginalResolution[], + selectedResolution: string +) { + return http.post('/api/vm/input-region', { + mode: 'manual', + resolutions, + selectedResolution + }); +} + +export function setSelectedOriginalResolution(selectedResolution: string) { + return http.post('/api/vm/input-region', { mode: 'manual', selectedResolution }); +} + // get memory limit export function getMemoryLimit() { return http.get('/api/vm/memory/limit'); diff --git a/web/src/components/menu-item.tsx b/web/src/components/menu-item.tsx index 5353221..d1f7c4f 100644 --- a/web/src/components/menu-item.tsx +++ b/web/src/components/menu-item.tsx @@ -1,9 +1,9 @@ -import { ReactNode, useState } from 'react'; +import { ReactNode, useEffect, useRef, useState } from 'react'; import { Popover, Tooltip } from 'antd'; -import { useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useMediaQuery } from 'react-responsive'; -import { submenuOpenCountAtom } from '@/jotai/settings.ts'; +import { menuCloseSignalAtom, submenuOpenCountAtom } from '@/jotai/settings.ts'; type MenuItemProps = { title: string; @@ -24,9 +24,27 @@ export const MenuItem = ({ }: MenuItemProps) => { const isBigScreen = useMediaQuery({ minWidth: 640 }); const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom); + const menuCloseSignal = useAtomValue(menuCloseSignalAtom); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isTooltipOpen, setIsTooltipOpen] = useState(false); + const handledCloseSignalRef = useRef(0); + + useEffect(() => { + if ( + menuCloseSignal === 0 || + menuCloseSignal === handledCloseSignalRef.current || + !isPopoverOpen + ) { + return; + } + + handledCloseSignalRef.current = menuCloseSignal; + setIsPopoverOpen(false); + setIsTooltipOpen(false); + setSubmenuOpenCount((count) => Math.max(0, count - 1)); + onOpenChange?.(false); + }, [isPopoverOpen, menuCloseSignal, onOpenChange, setSubmenuOpenCount]); function togglePopover(open: boolean) { setIsTooltipOpen(false); diff --git a/web/src/i18n/locales/ca.ts b/web/src/i18n/locales/ca.ts index 558c737..30f2e04 100644 --- a/web/src/i18n/locales/ca.ts +++ b/web/src/i18n/locales/ca.ts @@ -64,6 +64,36 @@ const ca = { video: 'Mode de vídeo', videoDirectTips: "Activa HTTPS a 'Configuració > Dispositiu' per utilitzar aquest mode", resolution: 'Resolució', + controlRegion: { + title: 'Calibratge del ratolí', + description: + 'Utilitzeu aquesta opció quan el dispositiu controlat tingui una resolució que no sigui 16:9 i el cursor estigui desalineat horitzontalment o verticalment.', + off: 'Desactivat', + auto: 'Automàtic', + autoWarning: + "El calibratge pot fallar quan l'aplicació de l'usuari tingui un fons completament negre.", + manual: 'Manual', + originalResolution: 'Resolució original', + selectResolution: 'Selecciona la resolució original', + useSelectedArea: "Utilitza la resolució de l'àrea seleccionada", + addResolution: 'Afegeix una resolució personalitzada', + add: 'Afegeix', + duplicateResolution: 'Aquesta resolució ja existeix.', + width: 'Amplada', + height: 'Alçada', + apply: 'Calcula i aplica', + invalidResolution: 'Introduïu una resolució original vàlida quan el vídeo estigui llest.', + select: "Selecciona l'àrea", + clear: 'Restaura la detecció automàtica', + saveFailed: "No s'ha pogut desar l'àrea d'entrada.", + tooSmall: "L'àrea seleccionada és massa petita.", + previewUnavailable: 'Vista prèvia no disponible', + clearConfirm: 'Voleu restaurar la detecció automàtica de vores negres?', + dragHint: "Arrossegueu per seleccionar l'àrea de l'escriptori remot", + finish: 'Fet', + confirm: 'Confirma', + cancel: 'Cancel·la' + }, auto: 'Automàtic', autoTips: "Poden aparèixer talls o desajustos del ratolí en certes resolucions. Prova a canviar la resolució de l'amfitrió remot o desactiva el mode automàtic.", diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index 2016f2b..7d35fdf 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -65,6 +65,35 @@ const cz = { video: 'Režim videa', videoDirectTips: 'Chcete-li používat tento režim, povolte HTTPS v "Nastavení > Zařízení"', resolution: 'Rozlišení', + controlRegion: { + title: 'Kalibrace myši', + description: + 'Toto nastavení použijte, pokud ovládané zařízení používá jiné rozlišení než 16:9 a kurzor je vodorovně nebo svisle posunutý.', + off: 'Vypnuto', + auto: 'Automaticky', + autoWarning: 'Kalibrace může selhat, pokud má uživatelská aplikace zcela černé pozadí.', + manual: 'Ručně', + originalResolution: 'Původní rozlišení', + selectResolution: 'Vyberte původní rozlišení', + useSelectedArea: 'Použít rozlišení vybrané oblasti', + addResolution: 'Přidat vlastní rozlišení', + add: 'Přidat', + duplicateResolution: 'Toto rozlišení již existuje.', + width: 'Šířka', + height: 'Výška', + apply: 'Vypočítat a použít', + invalidResolution: 'Po načtení videa zadejte platné původní rozlišení.', + select: 'Vybrat oblast', + clear: 'Obnovit automatickou detekci', + saveFailed: 'Vstupní oblast se nepodařilo uložit.', + tooSmall: 'Vybraná oblast je příliš malá.', + previewUnavailable: 'Náhled není k dispozici', + clearConfirm: 'Obnovit automatickou detekci černých okrajů?', + dragHint: 'Tažením vyberte oblast vzdálené plochy', + finish: 'Hotovo', + confirm: 'Potvrdit', + cancel: 'Zrušit' + }, auto: 'Automatické', autoTips: 'Může docházet k trhání obrazu nebo posunu myši při určitých rozlišeních. Zvažte úpravu rozlišení vzdáleného hostitele nebo vypněte automatický režim.', diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index 66b8e96..614c7d8 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -64,6 +64,36 @@ const da = { video: 'Videotilstand', videoDirectTips: 'Aktiver HTTPS i "Indstillinger > Enhed" for at bruge denne tilstand', resolution: 'Opløsning', + controlRegion: { + title: 'Musekalibrering', + description: + 'Brug denne indstilling, når den styrede enhed bruger en opløsning, der ikke er 16:9, og markøren er forskudt vandret eller lodret.', + off: 'Fra', + auto: 'Automatisk', + autoWarning: + 'Kalibreringen kan mislykkes, hvis brugerprogrammet har en helt sort baggrund.', + manual: 'Manuel', + originalResolution: 'Oprindelig opløsning', + selectResolution: 'Vælg oprindelig opløsning', + useSelectedArea: 'Brug det valgte områdes opløsning', + addResolution: 'Tilføj brugerdefineret opløsning', + add: 'Tilføj', + duplicateResolution: 'Denne opløsning findes allerede.', + width: 'Bredde', + height: 'Højde', + apply: 'Beregn og anvend', + invalidResolution: 'Indtast en gyldig oprindelig opløsning, når videoen er klar.', + select: 'Vælg område', + clear: 'Gendan automatisk registrering', + saveFailed: 'Inputområdet kunne ikke gemmes.', + tooSmall: 'Det valgte område er for lille.', + previewUnavailable: 'Forhåndsvisning er ikke tilgængelig', + clearConfirm: 'Gendan automatisk registrering af sorte kanter?', + dragHint: 'Træk for at vælge fjernskrivebordets område', + finish: 'Færdig', + confirm: 'Bekræft', + cancel: 'Annuller' + }, auto: 'Automatisk', autoTips: 'Screen-tearing eller mouse-offset kan opstå ved enkelte opløsninger. Hvis du oplever dette, kan du prøve at justere fjerncomputerens skærmopløsning eller deaktivere automatisk tilstand.', diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index 49d91ff..181764c 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -67,6 +67,37 @@ const de = { videoDirectTips: 'Aktivieren Sie HTTPS unter „Einstellungen > Gerät“, um diesen Modus zu verwenden', resolution: 'Auflösung', + controlRegion: { + title: 'Mauskalibrierung', + description: + 'Verwenden Sie diese Einstellung, wenn das gesteuerte Gerät eine andere Auflösung als 16:9 verwendet und der Mauszeiger horizontal oder vertikal versetzt ist.', + off: 'Aus', + auto: 'Automatisch', + autoWarning: + 'Die Kalibrierung kann fehlschlagen, wenn die Benutzeranwendung einen vollständig schwarzen Hintergrund hat.', + manual: 'Manuell', + originalResolution: 'Originalauflösung', + selectResolution: 'Originalauflösung auswählen', + useSelectedArea: 'Auflösung des ausgewählten Bereichs verwenden', + addResolution: 'Benutzerdefinierte Auflösung hinzufügen', + add: 'Hinzufügen', + duplicateResolution: 'Diese Auflösung ist bereits vorhanden.', + width: 'Breite', + height: 'Höhe', + apply: 'Berechnen und anwenden', + invalidResolution: + 'Geben Sie eine gültige Originalauflösung ein, sobald das Video bereit ist.', + select: 'Bereich auswählen', + clear: 'Automatische Erkennung wiederherstellen', + saveFailed: 'Der Eingabebereich konnte nicht gespeichert werden.', + tooSmall: 'Der ausgewählte Bereich ist zu klein.', + previewUnavailable: 'Vorschau nicht verfügbar', + clearConfirm: 'Automatische Erkennung schwarzer Ränder wiederherstellen?', + dragHint: 'Ziehen Sie, um den Remote-Desktop-Bereich auszuwählen', + finish: 'Fertig', + confirm: 'Bestätigen', + cancel: 'Abbrechen' + }, auto: 'Automatisch', autoTips: 'Bildverzerrungen oder ein versetzter Mauszeiger können bei bestimmten Auflösungen auftreten. Versuchen Sie, die Auflösung des entfernten Hosts anzupassen oder den automatischen Modus zu deaktivieren.', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 23c1c38..b4bbe9b 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -66,6 +66,35 @@ const en = { video: 'Video Mode', videoDirectTips: 'Enable HTTPS in "Settings > Device" to use this mode', resolution: 'Resolution', + controlRegion: { + title: 'Mouse Calibration', + description: + 'Use this setting when the controlled device uses a non-16:9 resolution and the cursor is misaligned horizontally or vertically.', + off: 'Off', + auto: 'Auto', + autoWarning: 'Calibration may fail when the user application has a pure black background.', + manual: 'Manual', + originalResolution: 'Original Resolution', + selectResolution: 'Select original resolution', + useSelectedArea: 'Use selected area resolution', + addResolution: 'Add custom resolution', + add: 'Add', + duplicateResolution: 'This resolution already exists.', + width: 'Width', + height: 'Height', + apply: 'Calculate and Apply', + invalidResolution: 'Enter a valid original resolution after the video is ready.', + select: 'Select Area', + clear: 'Restore Automatic', + saveFailed: 'Failed to save the input area.', + tooSmall: 'The selected area is too small.', + previewUnavailable: 'Preview unavailable', + clearConfirm: 'Restore automatic black-border detection?', + dragHint: 'Drag to select the remote desktop area', + finish: 'Done', + confirm: 'Confirm', + cancel: 'Cancel' + }, auto: 'Automatic', autoTips: "Screen tearing or mouse offset may occur at specific resolutions. Consider adjusting the remote host's resolution or disable automatic mode.", diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index 5e7c4e7..928aca4 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -65,6 +65,36 @@ const es = { video: 'Modo de vídeo', videoDirectTips: 'Habilita HTTPS en "Ajustes > Dispositivo" para usar este modo', resolution: 'Resolución', + controlRegion: { + title: 'Calibración del ratón', + description: + 'Utilice este ajuste cuando el dispositivo controlado use una resolución distinta de 16:9 y el cursor esté desalineado horizontal o verticalmente.', + off: 'Desactivado', + auto: 'Automático', + autoWarning: + 'La calibración puede fallar si la aplicación del usuario tiene un fondo completamente negro.', + manual: 'Manual', + originalResolution: 'Resolución original', + selectResolution: 'Seleccionar resolución original', + useSelectedArea: 'Usar la resolución del área seleccionada', + addResolution: 'Añadir resolución personalizada', + add: 'Añadir', + duplicateResolution: 'Esta resolución ya existe.', + width: 'Ancho', + height: 'Alto', + apply: 'Calcular y aplicar', + invalidResolution: 'Introduzca una resolución original válida cuando el vídeo esté listo.', + select: 'Seleccionar área', + clear: 'Restaurar detección automática', + saveFailed: 'No se pudo guardar el área de entrada.', + tooSmall: 'El área seleccionada es demasiado pequeña.', + previewUnavailable: 'Vista previa no disponible', + clearConfirm: '¿Restaurar la detección automática de bordes negros?', + dragHint: 'Arrastre para seleccionar el área del escritorio remoto', + finish: 'Listo', + confirm: 'Confirmar', + cancel: 'Cancelar' + }, auto: 'Automático', autoTips: 'En determinadas resoluciones pueden producirse rasgado de imagen (tearing) o desplazamiento del ratón. Prueba a ajustar la resolución del host remoto o desactiva el modo automático.', diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index 43a31dc..a28aa11 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -67,6 +67,36 @@ const fr = { video: 'Mode vidéo', videoDirectTips: 'Activez HTTPS dans "Paramètres > Appareil" pour utiliser ce mode', resolution: 'Résolution', + controlRegion: { + title: 'Étalonnage de la souris', + description: + "Utilisez ce réglage lorsque l'appareil contrôlé utilise une résolution autre que 16:9 et que le curseur est décalé horizontalement ou verticalement.", + off: 'Désactivé', + auto: 'Automatique', + autoWarning: + "L'étalonnage peut échouer si l'application utilisateur présente un arrière-plan entièrement noir.", + manual: 'Manuel', + originalResolution: "Résolution d'origine", + selectResolution: "Sélectionner la résolution d'origine", + useSelectedArea: 'Utiliser la résolution de la zone sélectionnée', + addResolution: 'Ajouter une résolution personnalisée', + add: 'Ajouter', + duplicateResolution: 'Cette résolution existe déjà.', + width: 'Largeur', + height: 'Hauteur', + apply: 'Calculer et appliquer', + invalidResolution: "Saisissez une résolution d'origine valide une fois la vidéo prête.", + select: 'Sélectionner une zone', + clear: 'Rétablir la détection automatique', + saveFailed: "Échec de l'enregistrement de la zone d'entrée.", + tooSmall: 'La zone sélectionnée est trop petite.', + previewUnavailable: 'Aperçu indisponible', + clearConfirm: 'Rétablir la détection automatique des bordures noires ?', + dragHint: 'Faites glisser pour sélectionner la zone du bureau distant', + finish: 'Terminé', + confirm: 'Confirmer', + cancel: 'Annuler' + }, auto: 'Automatique', autoTips: "Sous certaines résolutions, il peut y avoir des artefacts visuels ou un décalage de la souris. Veuillez ajuster la résolution de l'hôte distant ou désactiver le mode automatique.", diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index bacdbd7..a6a2a0d 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -66,6 +66,36 @@ const hu = { videoDirectTips: 'Engedélyezze az HTTPS elemet a "Beállítások > Eszköz" menüpontban ennek a módnak a használatához', resolution: 'Felbontás', + controlRegion: { + title: 'Egérkalibrálás', + description: + 'Akkor használja ezt a beállítást, ha a vezérelt eszköz nem 16:9 képarányú felbontást használ, és a kurzor vízszintesen vagy függőlegesen eltolódik.', + off: 'Kikapcsolva', + auto: 'Automatikus', + autoWarning: + 'A kalibrálás sikertelen lehet, ha a felhasználói alkalmazás háttere teljesen fekete.', + manual: 'Kézi', + originalResolution: 'Eredeti felbontás', + selectResolution: 'Válassza ki az eredeti felbontást', + useSelectedArea: 'A kijelölt terület felbontásának használata', + addResolution: 'Egyéni felbontás hozzáadása', + add: 'Hozzáadás', + duplicateResolution: 'Ez a felbontás már létezik.', + width: 'Szélesség', + height: 'Magasság', + apply: 'Számítás és alkalmazás', + invalidResolution: 'A videó betöltése után adjon meg érvényes eredeti felbontást.', + select: 'Terület kijelölése', + clear: 'Automatikus felismerés visszaállítása', + saveFailed: 'Nem sikerült menteni a bemeneti területet.', + tooSmall: 'A kijelölt terület túl kicsi.', + previewUnavailable: 'Az előnézet nem érhető el', + clearConfirm: 'Visszaállítja a fekete szegélyek automatikus felismerését?', + dragHint: 'Húzással jelölje ki a távoli asztal területét', + finish: 'Kész', + confirm: 'Megerősítés', + cancel: 'Mégse' + }, auto: 'Automatikus', autoTips: 'Bizonyos felbontások esetén képernyőszakadás vagy egéreltolódás léphet fel. Fontolja meg a távoli gép felbontásának módosítását vagy az automatikus mód kikapcsolását.', diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index 01aa0a1..9a484af 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -64,6 +64,36 @@ const id = { video: 'Mode Video', videoDirectTips: 'Aktifkan HTTPS di "Pengaturan > Perangkat" untuk menggunakan mode ini', resolution: 'Resolusi', + controlRegion: { + title: 'Kalibrasi Tetikus', + description: + 'Gunakan pengaturan ini saat perangkat yang dikontrol menggunakan resolusi selain 16:9 dan posisi kursor tidak sejajar secara horizontal atau vertikal.', + off: 'Nonaktif', + auto: 'Otomatis', + autoWarning: + 'Kalibrasi mungkin gagal jika aplikasi pengguna menggunakan latar belakang hitam pekat.', + manual: 'Manual', + originalResolution: 'Resolusi Asli', + selectResolution: 'Pilih resolusi asli', + useSelectedArea: 'Gunakan resolusi area yang dipilih', + addResolution: 'Tambahkan resolusi khusus', + add: 'Tambah', + duplicateResolution: 'Resolusi ini sudah ada.', + width: 'Lebar', + height: 'Tinggi', + apply: 'Hitung dan Terapkan', + invalidResolution: 'Masukkan resolusi asli yang valid setelah video siap.', + select: 'Pilih Area', + clear: 'Pulihkan Otomatis', + saveFailed: 'Gagal menyimpan area input.', + tooSmall: 'Area yang dipilih terlalu kecil.', + previewUnavailable: 'Pratinjau tidak tersedia', + clearConfirm: 'Pulihkan deteksi batas hitam otomatis?', + dragHint: 'Seret untuk memilih area desktop jarak jauh', + finish: 'Selesai', + confirm: 'Konfirmasi', + cancel: 'Batal' + }, auto: 'Otomatis', autoTips: 'Tearing layar atau offset tetikus dapat terjadi pada resolusi tertentu. Pertimbangkan untuk menyesuaikan resolusi host jarak jauh atau menonaktifkan mode otomatis.', diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index 921947a..1e419ad 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -66,6 +66,36 @@ const it = { videoDirectTips: 'Abilita HTTPS in "Impostazioni > Dispositivo" per utilizzare questa modalità', resolution: 'Risoluzione', + controlRegion: { + title: 'Calibrazione del mouse', + description: + 'Utilizza questa impostazione quando il dispositivo controllato usa una risoluzione diversa da 16:9 e il cursore risulta disallineato orizzontalmente o verticalmente.', + off: 'Disattivata', + auto: 'Automatica', + autoWarning: + "La calibrazione potrebbe non riuscire se l'applicazione utente ha uno sfondo completamente nero.", + manual: 'Manuale', + originalResolution: 'Risoluzione originale', + selectResolution: 'Seleziona la risoluzione originale', + useSelectedArea: "Usa la risoluzione dell'area selezionata", + addResolution: 'Aggiungi una risoluzione personalizzata', + add: 'Aggiungi', + duplicateResolution: 'Questa risoluzione esiste già.', + width: 'Larghezza', + height: 'Altezza', + apply: 'Calcola e applica', + invalidResolution: 'Inserisci una risoluzione originale valida quando il video è pronto.', + select: 'Seleziona area', + clear: 'Ripristina il rilevamento automatico', + saveFailed: "Impossibile salvare l'area di input.", + tooSmall: "L'area selezionata è troppo piccola.", + previewUnavailable: 'Anteprima non disponibile', + clearConfirm: 'Ripristinare il rilevamento automatico dei bordi neri?', + dragHint: "Trascina per selezionare l'area del desktop remoto", + finish: 'Fine', + confirm: 'Conferma', + cancel: 'Annulla' + }, auto: 'Automatico', autoTips: 'Potrebbero verificarsi tearing dello schermo o spostamento del mouse a risoluzioni specifiche. Considera di regolare la risoluzione del dispositivo remoto o disabilitare la modalità automatica.', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 50e11f4..c305acd 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -64,6 +64,36 @@ const ja = { video: 'ビデオモード', videoDirectTips: 'このモードを使用するには「設定 - デバイス」で HTTPS を有効にしてください', resolution: '解像度', + controlRegion: { + title: 'マウス位置補正', + description: + '操作対象のデバイスが 16:9 以外の解像度を使用していて、カーソルの位置が水平方向または垂直方向にずれる場合に使用します。', + off: 'オフ', + auto: '自動', + autoWarning: + 'ユーザーアプリケーションの背景が完全な黒の場合、補正に失敗することがあります。', + manual: '手動', + originalResolution: '元の解像度', + selectResolution: '元の解像度を選択', + useSelectedArea: '選択領域の解像度を使用', + addResolution: 'カスタム解像度を追加', + add: '追加', + duplicateResolution: 'この解像度はすでに存在します。', + width: '幅', + height: '高さ', + apply: '計算して適用', + invalidResolution: 'ビデオの準備完了後、有効な元の解像度を入力してください。', + select: '領域を選択', + clear: '自動に戻す', + saveFailed: '入力領域を保存できませんでした。', + tooSmall: '選択した領域が小さすぎます。', + previewUnavailable: 'プレビューを利用できません', + clearConfirm: '黒帯の自動検出に戻しますか?', + dragHint: 'ドラッグしてリモートデスクトップの領域を選択', + finish: '完了', + confirm: '確認', + cancel: 'キャンセル' + }, auto: '自動', autoTips: '特定の解像度で画面のちらつきやマウスカーソルのずれが発生する場合があります。リモートホストの解像度を調整するか、自動モードを無効にしてください。', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 132b322..e1b2d61 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -63,6 +63,35 @@ const ko = { video: '비디오 모드', videoDirectTips: '이 모드를 사용하려면 "설정 > 장치"에서 HTTPS를 활성화하세요', resolution: '해상도', + controlRegion: { + title: '마우스 보정', + description: + '제어 대상 장치가 16:9가 아닌 해상도를 사용하며 커서가 가로 또는 세로 방향으로 어긋날 때 이 설정을 사용하세요.', + off: '끄기', + auto: '자동', + autoWarning: '사용자 애플리케이션의 배경이 완전히 검은색이면 보정에 실패할 수 있습니다.', + manual: '수동', + originalResolution: '원본 해상도', + selectResolution: '원본 해상도 선택', + useSelectedArea: '선택한 영역의 해상도 사용', + addResolution: '사용자 지정 해상도 추가', + add: '추가', + duplicateResolution: '이미 존재하는 해상도입니다.', + width: '너비', + height: '높이', + apply: '계산 후 적용', + invalidResolution: '비디오가 준비되면 올바른 원본 해상도를 입력하세요.', + select: '영역 선택', + clear: '자동으로 복원', + saveFailed: '입력 영역을 저장하지 못했습니다.', + tooSmall: '선택한 영역이 너무 작습니다.', + previewUnavailable: '미리보기를 사용할 수 없습니다', + clearConfirm: '자동 검은색 테두리 감지로 복원하시겠습니까?', + dragHint: '드래그하여 원격 데스크톱 영역을 선택하세요', + finish: '완료', + confirm: '확인', + cancel: '취소' + }, auto: '자동 설정', autoTips: '일부 해상도에서는 화면이 왜곡되거나 마우스 동작이 비정상적으로 나타날 수 있습니다. 원격 컴퓨터의 해상도를 변경하거나 자동 설정 대신 수동 설정을 사용해 보세요.', diff --git a/web/src/i18n/locales/nb.ts b/web/src/i18n/locales/nb.ts index 3d6f3b3..cad7c37 100644 --- a/web/src/i18n/locales/nb.ts +++ b/web/src/i18n/locales/nb.ts @@ -65,6 +65,36 @@ const nb = { video: 'Video-kodek', videoDirectTips: 'Aktiver HTTPS i "Innstillinger > Enhet" for å bruke denne modusen', resolution: 'Oppløsning', + controlRegion: { + title: 'Musekalibrering', + description: + 'Bruk denne innstillingen når den kontrollerte enheten bruker en oppløsning som ikke er 16:9, og markøren er forskjøvet vannrett eller loddrett.', + off: 'Av', + auto: 'Automatisk', + autoWarning: + 'Kalibreringen kan mislykkes hvis brukerprogrammet har en helt svart bakgrunn.', + manual: 'Manuell', + originalResolution: 'Opprinnelig oppløsning', + selectResolution: 'Velg opprinnelig oppløsning', + useSelectedArea: 'Bruk oppløsningen til det valgte området', + addResolution: 'Legg til egendefinert oppløsning', + add: 'Legg til', + duplicateResolution: 'Denne oppløsningen finnes allerede.', + width: 'Bredde', + height: 'Høyde', + apply: 'Beregn og bruk', + invalidResolution: 'Angi en gyldig opprinnelig oppløsning når videoen er klar.', + select: 'Velg område', + clear: 'Gjenopprett automatisk registrering', + saveFailed: 'Kunne ikke lagre inndataområdet.', + tooSmall: 'Det valgte området er for lite.', + previewUnavailable: 'Forhåndsvisning er utilgjengelig', + clearConfirm: 'Gjenopprette automatisk registrering av svarte kanter?', + dragHint: 'Dra for å velge området på det eksterne skrivebordet', + finish: 'Ferdig', + confirm: 'Bekreft', + cancel: 'Avbryt' + }, auto: 'Automatisk', autoTips: 'Skjermriving eller peker-forskyvning kan oppstå ved enkelte oppløsninger. Prøv å justere den eksterne vertens oppløsning eller skru av automatisk modus.', diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index ab37893..37e22d6 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -66,6 +66,37 @@ const nl = { video: 'Videomodus', videoDirectTips: 'Schakel HTTPS in "Instellingen > Apparaat" in om deze modus te gebruiken', resolution: 'Resolutie', + controlRegion: { + title: 'Muiskalibratie', + description: + 'Gebruik deze instelling wanneer het bestuurde apparaat een andere resolutie dan 16:9 gebruikt en de cursor horizontaal of verticaal niet goed is uitgelijnd.', + off: 'Uit', + auto: 'Automatisch', + autoWarning: + 'De kalibratie kan mislukken als de gebruikerstoepassing een volledig zwarte achtergrond heeft.', + manual: 'Handmatig', + originalResolution: 'Oorspronkelijke resolutie', + selectResolution: 'Oorspronkelijke resolutie selecteren', + useSelectedArea: 'Resolutie van geselecteerd gebied gebruiken', + addResolution: 'Aangepaste resolutie toevoegen', + add: 'Toevoegen', + duplicateResolution: 'Deze resolutie bestaat al.', + width: 'Breedte', + height: 'Hoogte', + apply: 'Berekenen en toepassen', + invalidResolution: + 'Voer een geldige oorspronkelijke resolutie in zodra de video gereed is.', + select: 'Gebied selecteren', + clear: 'Automatische detectie herstellen', + saveFailed: 'Het invoergebied kan niet worden opgeslagen.', + tooSmall: 'Het geselecteerde gebied is te klein.', + previewUnavailable: 'Voorbeeld niet beschikbaar', + clearConfirm: 'Automatische detectie van zwarte randen herstellen?', + dragHint: 'Sleep om het externe bureaubladgebied te selecteren', + finish: 'Gereed', + confirm: 'Bevestigen', + cancel: 'Annuleren' + }, auto: 'Automatisch', autoTips: 'Bij bepaalde resoluties kunnen schermverscheuringen of muisverplaatsingen optreden. Overweeg de resolutie van de externe host aan te passen of schakel de automatische modus uit.', diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index dc848da..514e07e 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -65,6 +65,36 @@ const pl = { video: 'Tryb wideo', videoDirectTips: 'Włącz HTTPS w „Ustawienia > Urządzenie”, aby korzystać z tego trybu', resolution: 'Rozdzielczość', + controlRegion: { + title: 'Kalibracja myszy', + description: + 'Użyj tego ustawienia, gdy kontrolowane urządzenie korzysta z rozdzielczości innej niż 16:9, a kursor jest przesunięty w poziomie lub w pionie.', + off: 'Wyłączona', + auto: 'Automatyczna', + autoWarning: + 'Kalibracja może się nie powieść, gdy aplikacja użytkownika ma całkowicie czarne tło.', + manual: 'Ręczna', + originalResolution: 'Oryginalna rozdzielczość', + selectResolution: 'Wybierz oryginalną rozdzielczość', + useSelectedArea: 'Użyj rozdzielczości zaznaczonego obszaru', + addResolution: 'Dodaj niestandardową rozdzielczość', + add: 'Dodaj', + duplicateResolution: 'Ta rozdzielczość już istnieje.', + width: 'Szerokość', + height: 'Wysokość', + apply: 'Oblicz i zastosuj', + invalidResolution: 'Po uruchomieniu wideo wprowadź prawidłową oryginalną rozdzielczość.', + select: 'Zaznacz obszar', + clear: 'Przywróć automatyczne wykrywanie', + saveFailed: 'Nie udało się zapisać obszaru wejściowego.', + tooSmall: 'Zaznaczony obszar jest zbyt mały.', + previewUnavailable: 'Podgląd niedostępny', + clearConfirm: 'Przywrócić automatyczne wykrywanie czarnych obramowań?', + dragHint: 'Przeciągnij, aby zaznaczyć obszar pulpitu zdalnego', + finish: 'Gotowe', + confirm: 'Potwierdź', + cancel: 'Anuluj' + }, auto: 'Automatyczny', autoTips: 'W określonych rozdzielczościach może wystąpić rozrywanie ekranu lub przesunięcie myszy. Rozważ dostosowanie rozdzielczości zdalnego hosta lub wyłącz tryb automatyczny.', diff --git a/web/src/i18n/locales/pt_br.ts b/web/src/i18n/locales/pt_br.ts index 00bac05..a99433f 100644 --- a/web/src/i18n/locales/pt_br.ts +++ b/web/src/i18n/locales/pt_br.ts @@ -64,6 +64,36 @@ const pt_br = { video: 'Modo de Vídeo', videoDirectTips: 'Ative HTTPS em "Configurações > Dispositivo" para usar este modo', resolution: 'Resolução', + controlRegion: { + title: 'Calibração do mouse', + description: + 'Use esta configuração quando o dispositivo controlado usar uma resolução diferente de 16:9 e o cursor estiver desalinhado horizontal ou verticalmente.', + off: 'Desativado', + auto: 'Automático', + autoWarning: + 'A calibração pode falhar se o aplicativo do usuário tiver um fundo totalmente preto.', + manual: 'Manual', + originalResolution: 'Resolução original', + selectResolution: 'Selecionar resolução original', + useSelectedArea: 'Usar a resolução da área selecionada', + addResolution: 'Adicionar resolução personalizada', + add: 'Adicionar', + duplicateResolution: 'Esta resolução já existe.', + width: 'Largura', + height: 'Altura', + apply: 'Calcular e aplicar', + invalidResolution: 'Insira uma resolução original válida quando o vídeo estiver pronto.', + select: 'Selecionar área', + clear: 'Restaurar detecção automática', + saveFailed: 'Falha ao salvar a área de entrada.', + tooSmall: 'A área selecionada é muito pequena.', + previewUnavailable: 'Pré-visualização indisponível', + clearConfirm: 'Restaurar a detecção automática de bordas pretas?', + dragHint: 'Arraste para selecionar a área da área de trabalho remota', + finish: 'Concluir', + confirm: 'Confirmar', + cancel: 'Cancelar' + }, auto: 'Automático', autoTips: 'Rasgos na tela ou desvio do mouse podem ocorrer em resoluções específicas. Considere ajustar a resolução do host remoto ou desativar o modo automático.', diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index a676683..2fdb7b2 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -64,6 +64,36 @@ const ru = { video: 'Видеорежим', videoDirectTips: 'Включите HTTPS в "Настройки > Устройство", чтобы использовать этот режим', resolution: 'Разрешение', + controlRegion: { + title: 'Калибровка мыши', + description: + 'Используйте эту настройку, если управляемое устройство использует разрешение с соотношением сторон, отличным от 16:9, и курсор смещён по горизонтали или вертикали.', + off: 'Выкл.', + auto: 'Автоматически', + autoWarning: + 'Калибровка может завершиться неудачей, если фон пользовательского приложения полностью чёрный.', + manual: 'Вручную', + originalResolution: 'Исходное разрешение', + selectResolution: 'Выберите исходное разрешение', + useSelectedArea: 'Использовать разрешение выбранной области', + addResolution: 'Добавить пользовательское разрешение', + add: 'Добавить', + duplicateResolution: 'Такое разрешение уже существует.', + width: 'Ширина', + height: 'Высота', + apply: 'Рассчитать и применить', + invalidResolution: 'Введите допустимое исходное разрешение после появления видео.', + select: 'Выбрать область', + clear: 'Восстановить автоматически', + saveFailed: 'Не удалось сохранить область ввода.', + tooSmall: 'Выбранная область слишком мала.', + previewUnavailable: 'Предварительный просмотр недоступен', + clearConfirm: 'Восстановить автоматическое обнаружение чёрных полей?', + dragHint: 'Перетащите, чтобы выбрать область удалённого рабочего стола', + finish: 'Готово', + confirm: 'Подтвердить', + cancel: 'Отмена' + }, auto: 'Автоматическое', autoTips: 'При некоторых разрешениях экрана могут возникать артефакты изображения или смещение курсора. Пожалуйста, настройте разрешение удаленного компьютера или отключите автоматический режим для передачи видеопотока.', diff --git a/web/src/i18n/locales/se.ts b/web/src/i18n/locales/se.ts index 90d58ba..2e07934 100644 --- a/web/src/i18n/locales/se.ts +++ b/web/src/i18n/locales/se.ts @@ -62,6 +62,35 @@ const se = { video: 'Videoläge', videoDirectTips: 'Aktivera HTTPS i "Inställningar > Enhet" för att använda detta läge', resolution: 'Upplösning', + controlRegion: { + title: 'Muskalibrering', + description: + 'Använd den här inställningen när den styrda enheten använder en upplösning som inte är 16:9 och markören är feljusterad i sid- eller höjdled.', + off: 'Av', + auto: 'Automatisk', + autoWarning: 'Kalibreringen kan misslyckas om användarprogrammet har en helsvart bakgrund.', + manual: 'Manuell', + originalResolution: 'Ursprunglig upplösning', + selectResolution: 'Välj ursprunglig upplösning', + useSelectedArea: 'Använd upplösningen för det valda området', + addResolution: 'Lägg till anpassad upplösning', + add: 'Lägg till', + duplicateResolution: 'Den här upplösningen finns redan.', + width: 'Bredd', + height: 'Höjd', + apply: 'Beräkna och tillämpa', + invalidResolution: 'Ange en giltig ursprunglig upplösning när videon är klar.', + select: 'Välj område', + clear: 'Återställ automatiskt', + saveFailed: 'Det gick inte att spara inmatningsområdet.', + tooSmall: 'Det valda området är för litet.', + previewUnavailable: 'Förhandsvisning är inte tillgänglig', + clearConfirm: 'Återställa automatisk identifiering av svarta kanter?', + dragHint: 'Dra för att välja området för fjärrskrivbordet', + finish: 'Klar', + confirm: 'Bekräfta', + cancel: 'Avbryt' + }, auto: 'Automatisk', autoTips: 'Skärmtear eller musförskjutning kan förekomma vid vissa upplösningar. Överväg att justera fjärrvärdens upplösning eller inaktivera automatiskt läge.', diff --git a/web/src/i18n/locales/th.ts b/web/src/i18n/locales/th.ts index 7d7a9ec..929f2a9 100644 --- a/web/src/i18n/locales/th.ts +++ b/web/src/i18n/locales/th.ts @@ -62,6 +62,35 @@ const th = { video: 'โหมดวีดีโอ', videoDirectTips: 'เปิดใช้งาน HTTPS ใน "การตั้งค่า > อุปกรณ์" เพื่อใช้โหมดนี้', resolution: 'ความคมชัด', + controlRegion: { + title: 'ปรับเทียบเมาส์', + description: + 'ใช้การตั้งค่านี้เมื่ออุปกรณ์ที่ควบคุมใช้ความละเอียดที่ไม่ใช่ 16:9 และเคอร์เซอร์คลาดเคลื่อนในแนวนอนหรือแนวตั้ง', + off: 'ปิด', + auto: 'อัตโนมัติ', + autoWarning: 'การปรับเทียบอาจล้มเหลวเมื่อแอปพลิเคชันของผู้ใช้มีพื้นหลังสีดำสนิท', + manual: 'กำหนดเอง', + originalResolution: 'ความละเอียดต้นฉบับ', + selectResolution: 'เลือกความละเอียดต้นฉบับ', + useSelectedArea: 'ใช้ความละเอียดของพื้นที่ที่เลือก', + addResolution: 'เพิ่มความละเอียดแบบกำหนดเอง', + add: 'เพิ่ม', + duplicateResolution: 'มีความละเอียดนี้อยู่แล้ว', + width: 'ความกว้าง', + height: 'ความสูง', + apply: 'คำนวณและนำไปใช้', + invalidResolution: 'ป้อนความละเอียดต้นฉบับที่ถูกต้องหลังจากวิดีโอพร้อมใช้งาน', + select: 'เลือกพื้นที่', + clear: 'คืนค่าอัตโนมัติ', + saveFailed: 'บันทึกพื้นที่อินพุตไม่สำเร็จ', + tooSmall: 'พื้นที่ที่เลือกมีขนาดเล็กเกินไป', + previewUnavailable: 'ไม่สามารถดูตัวอย่างได้', + clearConfirm: 'คืนค่าการตรวจจับขอบสีดำอัตโนมัติหรือไม่', + dragHint: 'ลากเพื่อเลือกพื้นที่เดสก์ท็อประยะไกล', + finish: 'เสร็จสิ้น', + confirm: 'ยืนยัน', + cancel: 'ยกเลิก' + }, auto: 'อัตโนมัติ', autoTips: 'อาการภาพฉีกขาดหรือเมาส์ไม่ตรงตำแหน่งอาจเกิดขึ้นที่ความละเอียดบางระดับ แนะนำให้ปรับความละเอียดของคอมพิวเตอร์ต้นทางหรือปิดโหมดอัตโนมัติ', diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index 3a8c4cd..80abb96 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -65,6 +65,36 @@ const tr = { video: 'Görüntü modu', videoDirectTips: 'kullanmak için "Ayarlar > Cihaz" HTTPS aktif edin', resolution: 'Çözünürlük', + controlRegion: { + title: 'Fare Kalibrasyonu', + description: + 'Kontrol edilen cihaz 16:9 dışında bir çözünürlük kullandığında ve imleç yatay veya dikey olarak hizalanmadığında bu ayarı kullanın.', + off: 'Kapalı', + auto: 'Otomatik', + autoWarning: + 'Kullanıcı uygulamasının arka planı tamamen siyah olduğunda kalibrasyon başarısız olabilir.', + manual: 'Manuel', + originalResolution: 'Orijinal Çözünürlük', + selectResolution: 'Orijinal çözünürlüğü seçin', + useSelectedArea: 'Seçili alanın çözünürlüğünü kullan', + addResolution: 'Özel çözünürlük ekle', + add: 'Ekle', + duplicateResolution: 'Bu çözünürlük zaten mevcut.', + width: 'Genişlik', + height: 'Yükseklik', + apply: 'Hesapla ve Uygula', + invalidResolution: 'Video hazır olduktan sonra geçerli bir orijinal çözünürlük girin.', + select: 'Alan Seç', + clear: 'Otomatik Ayarı Geri Yükle', + saveFailed: 'Giriş alanı kaydedilemedi.', + tooSmall: 'Seçili alan çok küçük.', + previewUnavailable: 'Önizleme kullanılamıyor', + clearConfirm: 'Otomatik siyah kenar algılama geri yüklensin mi?', + dragHint: 'Uzak masaüstü alanını seçmek için sürükleyin', + finish: 'Bitti', + confirm: 'Onayla', + cancel: 'İptal' + }, auto: 'Otomatik', autoTips: 'Belirli çözünürlüklerde ekran yırtılması veya fare kayması meydana gelebilir. Bu durumda uzak ana bilgisayarın çözünürlüğünü ayarlamayı ya da otomatik modu devre dışı bırakmayı deneyin.', diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index 593b666..10e3706 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -65,6 +65,36 @@ const uk = { video: 'Відеорежим', videoDirectTips: 'Увімкніть HTTPS у "Налаштування > Пристрій", щоб використовувати цей режим', resolution: 'Роздільна здатність', + controlRegion: { + title: 'Калібрування миші', + description: + 'Використовуйте це налаштування, якщо керований пристрій має роздільну здатність зі співвідношенням сторін, відмінним від 16:9, а курсор зміщений по горизонталі або вертикалі.', + off: 'Вимкнено', + auto: 'Автоматично', + autoWarning: + 'Калібрування може завершитися невдало, якщо користувацька програма має повністю чорне тло.', + manual: 'Вручну', + originalResolution: 'Початкова роздільна здатність', + selectResolution: 'Виберіть початкову роздільну здатність', + useSelectedArea: 'Використати роздільну здатність вибраної області', + addResolution: 'Додати власну роздільну здатність', + add: 'Додати', + duplicateResolution: 'Така роздільна здатність уже існує.', + width: 'Ширина', + height: 'Висота', + apply: 'Обчислити й застосувати', + invalidResolution: 'Введіть дійсну початкову роздільну здатність після появи відео.', + select: 'Вибрати область', + clear: 'Відновити автоматично', + saveFailed: 'Не вдалося зберегти область введення.', + tooSmall: 'Вибрана область замала.', + previewUnavailable: 'Попередній перегляд недоступний', + clearConfirm: 'Відновити автоматичне виявлення чорних полів?', + dragHint: 'Перетягніть, щоб вибрати область віддаленого робочого столу', + finish: 'Готово', + confirm: 'Підтвердити', + cancel: 'Скасувати' + }, auto: 'Автоматично', autoTips: 'Може виникнути розрив зображення або зміщення миші при певних роздільних здатностях. Розгляньте можливість налаштування роздільної здатності віддаленого хоста або вимкнення автоматичного режиму для передачі відеопотоку.', diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index f2314c7..0ca2f1d 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -64,6 +64,36 @@ const vi = { video: 'Chế độ video', videoDirectTips: 'Bật HTTPS trong "Cài đặt > Thiết bị" để sử dụng chế độ này', resolution: 'Độ phân giải', + controlRegion: { + title: 'Hiệu chỉnh chuột', + description: + 'Sử dụng cài đặt này khi thiết bị được điều khiển dùng độ phân giải không phải tỷ lệ 16:9 và con trỏ bị lệch theo chiều ngang hoặc chiều dọc.', + off: 'Tắt', + auto: 'Tự động', + autoWarning: + 'Hiệu chỉnh có thể thất bại khi ứng dụng của người dùng có nền hoàn toàn màu đen.', + manual: 'Thủ công', + originalResolution: 'Độ phân giải gốc', + selectResolution: 'Chọn độ phân giải gốc', + useSelectedArea: 'Sử dụng độ phân giải của vùng đã chọn', + addResolution: 'Thêm độ phân giải tùy chỉnh', + add: 'Thêm', + duplicateResolution: 'Độ phân giải này đã tồn tại.', + width: 'Chiều rộng', + height: 'Chiều cao', + apply: 'Tính toán và áp dụng', + invalidResolution: 'Nhập độ phân giải gốc hợp lệ sau khi video sẵn sàng.', + select: 'Chọn vùng', + clear: 'Khôi phục tự động', + saveFailed: 'Không thể lưu vùng đầu vào.', + tooSmall: 'Vùng đã chọn quá nhỏ.', + previewUnavailable: 'Không thể xem trước', + clearConfirm: 'Khôi phục tính năng tự động phát hiện viền đen?', + dragHint: 'Kéo để chọn vùng màn hình từ xa', + finish: 'Xong', + confirm: 'Xác nhận', + cancel: 'Hủy' + }, auto: 'Tự động', autoTips: 'Màn hình bị xé hoặc lệch chuột có thể xảy ra ở các độ phân giải nhất định. Hãy xem xét điều chỉnh độ phân giải của máy remote hoặc tắt chế độ tự động.', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 7c5e1d7..7cce56a 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -64,6 +64,35 @@ const zh = { video: '视频模式', videoDirectTips: '该模式需启用 HTTPS,请前往「设置 - 设备」中开启', resolution: '分辨率', + controlRegion: { + title: '鼠标校准', + description: + '当受控设备为非 16:9 分辨率,且你发现光标在水平/垂直方向上不同步,则使用该设置。', + off: '关闭', + auto: '自动', + autoWarning: '若用户程序为纯黑色背景,则可能校准出错。', + manual: '手动', + originalResolution: '原始分辨率', + selectResolution: '选择原始分辨率', + useSelectedArea: '使用框选分辨率', + addResolution: '添加自定义分辨率', + add: '添加', + duplicateResolution: '该分辨率已存在。', + width: '宽度', + height: '高度', + apply: '换算并应用', + invalidResolution: '请在视频准备就绪后输入有效的原始分辨率。', + select: '框选区域', + clear: '恢复自动裁切', + saveFailed: '控制区域保存失败。', + tooSmall: '选择区域过小。', + previewUnavailable: '暂时无法预览', + clearConfirm: '恢复自动黑边检测?', + dragHint: '拖拽框选远程桌面区域', + finish: '完成', + confirm: '确定', + cancel: '取消' + }, auto: '自动', autoTips: '在某些分辨率下可能存在花屏或鼠标偏移的情况,请调整远程主机分辨率或者不使用自动模式。', diff --git a/web/src/i18n/locales/zh_tw.ts b/web/src/i18n/locales/zh_tw.ts index c570eef..8176c8f 100644 --- a/web/src/i18n/locales/zh_tw.ts +++ b/web/src/i18n/locales/zh_tw.ts @@ -61,6 +61,35 @@ const zh_tw = { video: '編碼格式', videoDirectTips: '本模式需先啟用 HTTPS,請前往「設定 -> 設備」中開啟', resolution: '解析度', + controlRegion: { + title: '滑鼠校正', + description: + '當受控裝置使用非 16:9 解析度,且游標在水平或垂直方向出現偏移時,請使用此設定。', + off: '關閉', + auto: '自動', + autoWarning: '當使用者應用程式使用全黑背景時,校正可能會失敗。', + manual: '手動', + originalResolution: '原始解析度', + selectResolution: '選擇原始解析度', + useSelectedArea: '使用所選區域的解析度', + addResolution: '新增自訂解析度', + add: '新增', + duplicateResolution: '此解析度已存在。', + width: '寬度', + height: '高度', + apply: '計算並套用', + invalidResolution: '請在影片準備完成後輸入有效的原始解析度。', + select: '選擇區域', + clear: '恢復自動偵測', + saveFailed: '無法儲存輸入區域。', + tooSmall: '所選區域太小。', + previewUnavailable: '無法預覽', + clearConfirm: '要恢復自動黑邊偵測嗎?', + dragHint: '拖曳以選擇遠端桌面區域', + finish: '完成', + confirm: '確認', + cancel: '取消' + }, auto: '自動', autoTips: '在某些特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。請調整遠端主機的解析度或停用自動模式。', diff --git a/web/src/jotai/screen.ts b/web/src/jotai/screen.ts index a385c5b..d5b26ba 100644 --- a/web/src/jotai/screen.ts +++ b/web/src/jotai/screen.ts @@ -1,6 +1,6 @@ import { atom } from 'jotai'; -import { Resolution } from '@/types'; +import { ControlRegionMode, InputRegion, Resolution } from '@/types'; export const isHdmiEnabledAtom = atom(true); @@ -14,3 +14,14 @@ export const videoScaleAtom = atom(1.0); // browser screen resolution export const resolutionAtom = atom(null); + +// currently effective absolute mouse input region +export const inputRegionAtom = atom(null); +export const manualInputRegionAtom = atom(null); +export const selectedOriginalResolutionAtom = atom(''); + +// device-level control region mode; disabled by default +export const controlRegionModeAtom = atom('off'); + +// show the live input-region selection overlay +export const inputRegionSelectingAtom = atom(false); diff --git a/web/src/jotai/settings.ts b/web/src/jotai/settings.ts index e59d9be..4d6900d 100644 --- a/web/src/jotai/settings.ts +++ b/web/src/jotai/settings.ts @@ -6,6 +6,9 @@ export const menuDisabledItemsAtom = atom([]); // track how many submenus are currently open export const submenuOpenCountAtom = atom(0); +// signal all toolbar menus to close +export const menuCloseSignalAtom = atom(0); + // web title export const webTitleAtom = atom(''); diff --git a/web/src/pages/desktop/index.tsx b/web/src/pages/desktop/index.tsx index a06337a..a533a52 100644 --- a/web/src/pages/desktop/index.tsx +++ b/web/src/pages/desktop/index.tsx @@ -1,13 +1,22 @@ import { useEffect, useState } from 'react'; import { Splitter } from 'antd'; -import { useAtom, useAtomValue } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; import { useMediaQuery } from 'react-responsive'; +import { getInputRegion, setControlRegionMode } from '@/api/vm.ts'; +import { ControlRegionConfig, InputRegion } from '@/types'; import * as storage from '@/lib/localstorage.ts'; import { client } from '@/lib/websocket.ts'; import { picoclawChatOpenAtom } from '@/jotai/picoclaw.ts'; -import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts'; +import { + controlRegionModeAtom, + inputRegionAtom, + manualInputRegionAtom, + resolutionAtom, + selectedOriginalResolutionAtom, + videoModeAtom +} from '@/jotai/screen.ts'; import { Head } from '@/components/head.tsx'; import { CaptureStatusOverlay, useCaptureStatus } from './capture-status'; @@ -18,6 +27,15 @@ import { H264ModeNotification, Notification } from './notification.tsx'; import { Sidebar as PicoclawSidebar } from './picoclaw'; import { ActionOverlay } from './picoclaw/action-overlay.tsx'; import { Screen } from './screen'; +import { AutoRegion } from './screen/auto-region.tsx'; +import { + getMediaSize, + isInputRegionCompatible, + isMediaReady, + isValidInputRegion +} from './screen/geometry.ts'; +import { InputRegionOverlay } from './screen/input-region-overlay.tsx'; +import { ManualRegion } from './screen/manual-region.tsx'; import { VirtualKeyboard } from './virtual-keyboard'; function getVideoMode() { @@ -43,6 +61,10 @@ export const Desktop = () => { const [videoMode, setVideoMode] = useAtom(videoModeAtom); const [resolution, setResolution] = useAtom(resolutionAtom); + const [inputRegion, setInputRegion] = useAtom(inputRegionAtom); + const [controlRegionMode, setControlRegionModeState] = useAtom(controlRegionModeAtom); + const setManualInputRegion = useSetAtom(manualInputRegionAtom); + const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom); const isPicoclawChatOpen = useAtomValue(picoclawChatOpenAtom); useEffect(() => { @@ -52,11 +74,113 @@ export const Desktop = () => { const res = storage.getResolution() || { width: 0, height: 0 }; setResolution(res); + setInputRegion(null); + setManualInputRegion(null); + setSelectedOriginalResolution(''); + setControlRegionModeState('off'); + + getInputRegion() + .then((rsp) => { + const config = rsp.data as ControlRegionConfig | null; + const mode = config?.mode || 'off'; + const manualRegion = isValidInputRegion(config as InputRegion) + ? (config as InputRegion) + : null; + const selectedResolution = config?.selectedResolution || ''; + setManualInputRegion(manualRegion); + setSelectedOriginalResolution(selectedResolution); + setInputRegion(mode === 'manual' && !selectedResolution ? manualRegion : null); + setControlRegionModeState(mode); + }) + .catch(() => { + setControlRegionModeState('off'); + setInputRegion(null); + setManualInputRegion(null); + setSelectedOriginalResolution(''); + }); return () => { client.close(); }; - }, [activeVideoMode, setResolution, setVideoMode]); + }, [ + activeVideoMode, + setControlRegionModeState, + setInputRegion, + setManualInputRegion, + setResolution, + setSelectedOriginalResolution, + setVideoMode + ]); + + useEffect(() => { + if (controlRegionMode !== 'manual' || !inputRegion) { + return; + } + + const screen = document.getElementById('screen'); + if (!screen) { + return; + } + const target = screen; + const region = inputRegion; + + let cleared = false; + let validationTimer: ReturnType | null = null; + function validateMediaSize() { + if (cleared) { + return; + } + + if (validationTimer !== null) { + clearTimeout(validationTimer); + } + validationTimer = setTimeout(() => { + const mediaSize = getMediaSize(target); + if (!mediaSize || !isMediaReady(target) || isInputRegionCompatible(region, mediaSize)) { + return; + } + + cleared = true; + setControlRegionMode('off') + .then((rsp) => { + if (rsp.code === 0) { + setControlRegionModeState('off'); + setInputRegion(null); + } + }) + .catch(() => undefined); + }, 300); + } + + validateMediaSize(); + const observer = new MutationObserver(validateMediaSize); + observer.observe(target, { + attributes: true, + attributeFilter: ['data-media-width', 'data-media-height'] + }); + target.addEventListener('load', validateMediaSize); + target.addEventListener('loadedmetadata', validateMediaSize); + target.addEventListener('canplay', validateMediaSize); + target.addEventListener('resize', validateMediaSize); + + return () => { + observer.disconnect(); + if (validationTimer !== null) { + clearTimeout(validationTimer); + } + target.removeEventListener('load', validateMediaSize); + target.removeEventListener('loadedmetadata', validateMediaSize); + target.removeEventListener('canplay', validateMediaSize); + target.removeEventListener('resize', validateMediaSize); + }; + }, [ + controlRegionMode, + inputRegion, + resolution, + setControlRegionModeState, + setInputRegion, + videoMode + ]); function handleSplitterResize(sizes: number[]) { const nextSidebarWidth = sizes[1]; @@ -98,6 +222,9 @@ export const Desktop = () => { + + + diff --git a/web/src/pages/desktop/menu/download.tsx b/web/src/pages/desktop/menu/download.tsx index 368cee9..f50ca28 100644 --- a/web/src/pages/desktop/menu/download.tsx +++ b/web/src/pages/desktop/menu/download.tsx @@ -60,7 +60,8 @@ export const DownloadImage = () => { // Keep monitoring an active remote download after the popover closes so // completion can still refresh an already-open image list. - if (!remoteDownloadActive.current) { + const transferActive = remoteDownloadActive.current || fileUploadActive.current; + if (!transferActive) { setInput(''); setSha256sum(''); setStatus(''); diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index e7859f7..570b227 100644 --- a/web/src/pages/desktop/menu/index.tsx +++ b/web/src/pages/desktop/menu/index.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useAuth } from '@/contexts/auth.ts'; import { Divider } from 'antd'; import clsx from 'clsx'; @@ -6,7 +6,11 @@ import { useAtomValue } from 'jotai'; import { GripVerticalIcon } from 'lucide-react'; import Draggable, { DraggableData, DraggableEvent } from 'react-draggable'; -import { keyboardLedStatusVisibleAtom, menuDisabledItemsAtom } from '@/jotai/settings.ts'; +import { + keyboardLedStatusVisibleAtom, + menuCloseSignalAtom, + menuDisabledItemsAtom +} from '@/jotai/settings.ts'; import { useMenuBounds } from '@/hooks/useMenuBounds.ts'; import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts'; @@ -31,6 +35,7 @@ export const Menu = () => { const isAdmin = account.role === 'admin'; const menuDisabledItems = useAtomValue(menuDisabledItemsAtom); + const menuCloseSignal = useAtomValue(menuCloseSignalAtom); const isKeyboardLedStatusVisible = useAtomValue(keyboardLedStatusVisibleAtom); const { @@ -44,6 +49,12 @@ export const Menu = () => { const menuBounds = useMenuBounds(nodeRef, isMenuExpanded); + useEffect(() => { + if (menuCloseSignal > 0) { + setIsMenuExpanded(false); + } + }, [menuCloseSignal, setIsMenuExpanded]); + function onDragStop(_e: DraggableEvent, data: DraggableData) { if (data.x === 0 && data.y === 0) return; handleMoved(); diff --git a/web/src/pages/desktop/menu/mouse/index.tsx b/web/src/pages/desktop/menu/mouse/index.tsx index feb59f1..d6a651f 100644 --- a/web/src/pages/desktop/menu/mouse/index.tsx +++ b/web/src/pages/desktop/menu/mouse/index.tsx @@ -13,6 +13,7 @@ import { } from '@/jotai/mouse'; import { MenuItem } from '@/components/menu-item.tsx'; +import { OriginalResolution } from '../screen/original-resolution.tsx'; import { Cursor } from './cursor.tsx'; import { Direction } from './direction.tsx'; import { HidMode } from './hid-mode.tsx'; @@ -58,6 +59,7 @@ export const Mouse = () => { + diff --git a/web/src/pages/desktop/menu/screen/original-resolution.tsx b/web/src/pages/desktop/menu/screen/original-resolution.tsx new file mode 100644 index 0000000..bcad02b --- /dev/null +++ b/web/src/pages/desktop/menu/screen/original-resolution.tsx @@ -0,0 +1,293 @@ +import { useEffect, useState } from 'react'; +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import { + Button, + InputNumber, + message, + Modal, + Popover, + Segmented, + Select, + Space, + Tooltip, + Typography +} from 'antd'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { ScanSearchIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { + getInputRegion, + setControlRegionMode, + setOriginalResolutionConfig, + setSelectedOriginalResolution +} from '@/api/vm.ts'; +import { + ControlRegionConfig, + ControlRegionMode, + InputRegion, + OriginalResolution as ResolutionPreset +} from '@/types'; +import { keyboardLockAtom } from '@/jotai/keyboard.ts'; +import { + controlRegionModeAtom, + inputRegionAtom, + inputRegionSelectingAtom, + manualInputRegionAtom, + selectedOriginalResolutionAtom +} from '@/jotai/screen.ts'; +import { menuCloseSignalAtom } from '@/jotai/settings.ts'; +import { isValidInputRegion } from '@/pages/desktop/screen/geometry.ts'; + +const resolutionKey = ({ width, height }: ResolutionPreset) => `${width}x${height}`; + +export const OriginalResolution = () => { + const { t } = useTranslation(); + const setInputRegion = useSetAtom(inputRegionAtom); + const manualInputRegion = useAtomValue(manualInputRegionAtom); + const setManualInputRegion = useSetAtom(manualInputRegionAtom); + const [selectedResolution, setSelectedResolution] = useAtom(selectedOriginalResolutionAtom); + const [mode, setMode] = useAtom(controlRegionModeAtom); + const setSelecting = useSetAtom(inputRegionSelectingAtom); + const setKeyboardLock = useSetAtom(keyboardLockAtom); + const requestMenuClose = useSetAtom(menuCloseSignalAtom); + const [messageApi, contextHolder] = message.useMessage(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isAddOpen, setIsAddOpen] = useState(false); + const [resolutions, setResolutions] = useState([]); + const [newWidth, setNewWidth] = useState(null); + const [newHeight, setNewHeight] = useState(null); + + useEffect(() => { + return () => { + setKeyboardLock({ source: 'control-region-popover', locked: false }); + setKeyboardLock({ source: 'control-region-resolution-modal', locked: false }); + }; + }, [setKeyboardLock]); + + useEffect(() => { + setKeyboardLock({ source: 'control-region-resolution-modal', locked: isAddOpen }); + }, [isAddOpen, setKeyboardLock]); + + async function loadConfig() { + const rsp = await getInputRegion(); + if (rsp.code === 0) { + const config = rsp.data as ControlRegionConfig; + setResolutions(config?.resolutions || []); + setSelectedResolution(config?.selectedResolution || ''); + setManualInputRegion( + isValidInputRegion(config as InputRegion) ? (config as InputRegion) : null + ); + } + return rsp; + } + + async function applyResolution(key: string) { + const rsp = await setSelectedOriginalResolution(key); + if (rsp.code !== 0) { + messageApi.error(t('screen.controlRegion.saveFailed')); + return; + } + setSelectedResolution(key); + setIsPopoverOpen(true); + } + + async function saveResolutions(next: ResolutionPreset[], nextSelected = selectedResolution) { + const rsp = await setOriginalResolutionConfig(next, nextSelected); + if (rsp.code !== 0) { + messageApi.error(t('screen.controlRegion.saveFailed')); + return false; + } + setResolutions(next); + return true; + } + + async function addResolution() { + if (!newWidth || !newHeight) { + messageApi.error(t('screen.controlRegion.invalidResolution')); + return; + } + const resolution = { width: newWidth, height: newHeight }; + if (resolutions.some((item) => resolutionKey(item) === resolutionKey(resolution))) { + messageApi.warning(t('screen.controlRegion.duplicateResolution')); + return; + } + if (await saveResolutions([...resolutions, resolution])) { + setNewWidth(null); + setNewHeight(null); + setIsAddOpen(false); + } + } + + async function deleteResolution(index: number) { + if (index < 0 || index >= resolutions.length) return; + const deleted = resolutionKey(resolutions[index]); + const nextSelected = selectedResolution === deleted ? '' : selectedResolution; + if ( + await saveResolutions( + resolutions.filter((_, itemIndex) => itemIndex !== index), + nextSelected + ) + ) { + if (!nextSelected) { + setSelectedResolution(''); + setInputRegion(manualInputRegion); + } + } + } + + async function updateMode(nextMode: ControlRegionMode) { + const rsp = await setControlRegionMode(nextMode); + if (rsp.code !== 0) { + messageApi.error(t('screen.controlRegion.saveFailed')); + return; + } + setMode(nextMode); + if (nextMode === 'manual') { + const config = await loadConfig(); + const manualRegion = isValidInputRegion(config.data as InputRegion) + ? (config.data as InputRegion) + : null; + setManualInputRegion(manualRegion); + setInputRegion( + (config.data as ControlRegionConfig)?.selectedResolution ? null : manualRegion + ); + } else { + setInputRegion(null); + } + } + + function selectArea() { + handleOpenChange(false); + requestMenuClose((signal) => signal + 1); + setSelecting(true); + } + + function handleOpenChange(open: boolean) { + setIsPopoverOpen(open); + setKeyboardLock({ source: 'control-region-popover', locked: open }); + if (open) loadConfig(); + } + + const content = ( +
+ + + block + value={mode} + options={[ + { label: t('screen.controlRegion.off'), value: 'off' }, + { label: t('screen.controlRegion.auto'), value: 'auto' }, + { label: t('screen.controlRegion.manual'), value: 'manual' } + ]} + onChange={updateMode} + /> + + {t('screen.controlRegion.description')} + + {mode === 'auto' && ( + + {t('screen.controlRegion.autoWarning')} + + )} + {mode === 'manual' && ( + <> + + {t('screen.controlRegion.originalResolution')} + +