From a778c2b2cb7d0f7edc618abb0290a3de1354a7a5 Mon Sep 17 00:00:00 2001 From: keai <1258512088@qq.com> Date: Tue, 28 Apr 2026 12:44:31 +0800 Subject: [PATCH] feat(web): improve login persistence and screen interactions --- web/src/assets/styles/index.css | 1 + web/src/hooks/useMenuBounds.ts | 9 +- web/src/hooks/useMenuSnap.ts | 113 +++++++ web/src/hooks/useScreenFitScale.ts | 63 ++++ web/src/i18n/locales/en.ts | 2 + web/src/i18n/locales/zh.ts | 2 + web/src/lib/localstorage.ts | 29 ++ web/src/pages/auth/login/index.tsx | 70 ++++- web/src/pages/desktop/menu/index.tsx | 301 ++++++++++++++++--- web/src/pages/desktop/screen/h264-direct.tsx | 13 +- web/src/pages/desktop/screen/h264-webrtc.tsx | 13 +- web/src/pages/desktop/screen/mjpeg.tsx | 15 +- 12 files changed, 577 insertions(+), 54 deletions(-) create mode 100644 web/src/hooks/useMenuSnap.ts create mode 100644 web/src/hooks/useScreenFitScale.ts diff --git a/web/src/assets/styles/index.css b/web/src/assets/styles/index.css index 9ec4fa7a..50e19b99 100644 --- a/web/src/assets/styles/index.css +++ b/web/src/assets/styles/index.css @@ -7,6 +7,7 @@ body { padding: 0; margin: 0; background: #000; + overflow: hidden; } ::-webkit-scrollbar { diff --git a/web/src/hooks/useMenuBounds.ts b/web/src/hooks/useMenuBounds.ts index 7bc8dbd3..b2d9e039 100644 --- a/web/src/hooks/useMenuBounds.ts +++ b/web/src/hooks/useMenuBounds.ts @@ -15,11 +15,10 @@ export function useMenuBounds( isMenuExpanded: boolean ): MenuBounds { const menuDisabledItems = useAtomValue(menuDisabledItemsAtom); - const [menuBounds, setMenuBounds] = useState({ - left: 0, - right: 0, - top: 0, - bottom: 0 + const [menuBounds, setMenuBounds] = useState(() => { + const hw = window.innerWidth / 2; + const hh = window.innerHeight; + return { left: -hw, right: hw, top: -100, bottom: hh }; }); const handleResize = useCallback(() => { diff --git a/web/src/hooks/useMenuSnap.ts b/web/src/hooks/useMenuSnap.ts new file mode 100644 index 00000000..6b07eecc --- /dev/null +++ b/web/src/hooks/useMenuSnap.ts @@ -0,0 +1,113 @@ +import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; + +import { + getMenuSnapEdge, + getMenuSnapOffset, + setMenuSnapEdge, + setMenuSnapOffset, + type SnapEdge +} from '@/lib/localstorage.ts'; + +/** Distance from each edge (px) that triggers snap */ +const SNAP_THRESHOLD = 80; +/** How many px of the panel remain visible when snapped (the "indicator strip") */ +export const SNAP_PEEK = 6; +/** Hover zone width (px) on the indicator strip to trigger pop-out */ +export const SNAP_HOVER_ZONE = 28; + +export interface SnapState { + edge: SnapEdge; + /** normalised position along the perpendicular axis [0,1] */ + offset: number; +} + +export interface UseMenuSnapReturn { + snapState: SnapState; + isSnapHovered: boolean; + /** Call at drag-stop with node rect + final absolute position */ + onDragStop: (nodeRef: RefObject) => void; + /** Update hover state when mouse enters/leaves indicator */ + setSnapHovered: (v: boolean) => void; + /** Un-snap (release from edge) */ + clearSnap: () => void; +} + +export function useMenuSnap(): UseMenuSnapReturn { + const [snapState, setSnapState] = useState(() => ({ + edge: getMenuSnapEdge(), + offset: getMenuSnapOffset() + })); + const [isSnapHovered, setIsSnapHoveredRaw] = useState(false); + const hoverTimerRef = useRef | null>(null); + + // Persist whenever snap changes + useEffect(() => { + setMenuSnapEdge(snapState.edge); + setMenuSnapOffset(snapState.offset); + }, [snapState]); + + const onDragStop = useCallback((nodeRef: RefObject) => { + const el = nodeRef.current; + if (!el) return; + + const rect = el.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + // Distances from each edge + const dTop = rect.top; + const dBottom = vh - rect.bottom; + const dLeft = rect.left; + const dRight = vw - rect.right; + + const min = Math.min(dTop, dBottom, dLeft, dRight); + + if (min > SNAP_THRESHOLD) { + // Not close enough to any edge — clear snap + setSnapState({ edge: null, offset: 0.5 }); + return; + } + + let edge: SnapEdge = null; + let offset = 0.5; + + if (min === dTop) { + edge = 'top'; + offset = (rect.left + rect.width / 2) / vw; + } else if (min === dBottom) { + edge = 'bottom'; + offset = (rect.left + rect.width / 2) / vw; + } else if (min === dLeft) { + edge = 'left'; + offset = (rect.top + rect.height / 2) / vh; + } else { + edge = 'right'; + offset = (rect.top + rect.height / 2) / vh; + } + + offset = Math.max(0.05, Math.min(0.95, offset)); + setSnapState({ edge, offset }); + }, []); + + const setSnapHovered = useCallback((v: boolean) => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + if (v) { + setIsSnapHoveredRaw(true); + } else { + // Small delay before hiding so cursor can travel to the expanded panel + hoverTimerRef.current = setTimeout(() => { + setIsSnapHoveredRaw(false); + }, 300); + } + }, []); + + const clearSnap = useCallback(() => { + setSnapState({ edge: null, offset: 0.5 }); + setIsSnapHoveredRaw(false); + }, []); + + return { snapState, isSnapHovered, onDragStop, setSnapHovered, clearSnap }; +} diff --git a/web/src/hooks/useScreenFitScale.ts b/web/src/hooks/useScreenFitScale.ts new file mode 100644 index 00000000..d1a21c53 --- /dev/null +++ b/web/src/hooks/useScreenFitScale.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef, useState } from 'react'; +import { useAtomValue } from 'jotai'; + +import { resolutionAtom } from '@/jotai/screen.ts'; + +/** + * Computes a fit-scale so that the remote screen (native resolution) is + * always contained within the given container element without overflow. + * + * Returns 1 when the native resolution is unknown or smaller than the container. + * The caller should apply: transform: scale(fitScale * userVideoScale) + * + * Mouse coordinate mapping in absolute.tsx remains correct because: + * - The element keeps its CSS size at native resolution (nativeW × nativeH) + * - transform: scale(S) makes getBoundingClientRect() return nativeW*S × nativeH*S + * - getMediaSize() returns the real native size (videoWidth/naturalWidth/canvas.width) + * - mediaRatio === elementRatio after uniform scale → getCorrectedCoords maps correctly + */ +export function useScreenFitScale(containerRef: React.RefObject): number { + const resolution = useAtomValue(resolutionAtom); + const [fitScale, setFitScale] = useState(1); + // Keep a stable ref to avoid re-creating the ResizeObserver on every render + const resolutionRef = useRef(resolution); + resolutionRef.current = resolution; + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + function compute() { + const res = resolutionRef.current; + if (!res?.width || !res?.height) { + setFitScale(1); + return; + } + const cw = el!.clientWidth; + const ch = el!.clientHeight; + if (cw === 0 || ch === 0) return; + setFitScale(Math.min(cw / res.width, ch / res.height)); + } + + compute(); + + const ro = new ResizeObserver(compute); + ro.observe(el); + return () => ro.disconnect(); + }, [containerRef]); + + // Also recompute when resolution changes + useEffect(() => { + const el = containerRef.current; + if (!el || !resolution?.width || !resolution?.height) { + setFitScale(1); + return; + } + const cw = el.clientWidth; + const ch = el.clientHeight; + if (cw === 0 || ch === 0) return; + setFitScale(Math.min(cw / resolution.width, ch / resolution.height)); + }, [containerRef, resolution]); + + return fitScale; +} diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 5fea0ba9..8d2a2959 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -28,6 +28,8 @@ const en = { ok: 'Ok', cancel: 'Cancel', loginButtonText: 'Login', + rememberPassword: 'Remember Password', + autoLogin: 'Auto Login', tips: { reset1: 'To reset the passwords, press and hold the BOOT button on the NanoKVM for 10 seconds.', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index b734a2f6..add513b5 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -28,6 +28,8 @@ const zh = { ok: '确定', cancel: '取消', loginButtonText: '登录', + rememberPassword: '记住密码', + autoLogin: '自动登录', tips: { reset1: '长按 NanoKVM 上的 BOOT 按键 10 秒钟来重置帐号。', reset2: '详细操作步骤可参考此文档:', diff --git a/web/src/lib/localstorage.ts b/web/src/lib/localstorage.ts index 2ad2e372..acdd7fda 100644 --- a/web/src/lib/localstorage.ts +++ b/web/src/lib/localstorage.ts @@ -18,6 +18,8 @@ const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language'; const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password'; const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items'; const MENU_AUTO_HIDE_KEY = 'nano-kvm-menu-auto-hide'; +const MENU_SNAP_EDGE_KEY = 'nano-kvm-menu-snap-edge'; +const MENU_SNAP_OFFSET_KEY = 'nano-kvm-menu-snap-offset'; const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm'; type ItemWithExpiry = { @@ -230,3 +232,30 @@ export function getPowerConfirm() { export function setPowerConfirm(enabled: boolean) { localStorage.setItem(POWER_CONFIRM_KEY, String(enabled)); } + +export type SnapEdge = 'top' | 'bottom' | 'left' | 'right' | null; + +export function getMenuSnapEdge(): SnapEdge { + const v = localStorage.getItem(MENU_SNAP_EDGE_KEY); + if (v === 'top' || v === 'bottom' || v === 'left' || v === 'right') return v; + return null; +} + +export function setMenuSnapEdge(edge: SnapEdge) { + if (edge === null) { + localStorage.removeItem(MENU_SNAP_EDGE_KEY); + } else { + localStorage.setItem(MENU_SNAP_EDGE_KEY, edge); + } +} + +// Perpendicular axis normalized position [0,1] when snapped (e.g. left/right edge => vertical %) +export function getMenuSnapOffset(): number { + const v = localStorage.getItem(MENU_SNAP_OFFSET_KEY); + const n = parseFloat(v ?? ''); + return isNaN(n) ? 0.5 : Math.max(0, Math.min(1, n)); +} + +export function setMenuSnapOffset(offset: number) { + localStorage.setItem(MENU_SNAP_OFFSET_KEY, String(offset)); +} diff --git a/web/src/pages/auth/login/index.tsx b/web/src/pages/auth/login/index.tsx index a5f7aeae..ef199ff2 100644 --- a/web/src/pages/auth/login/index.tsx +++ b/web/src/pages/auth/login/index.tsx @@ -1,6 +1,6 @@ import { ReactElement, useEffect, useState } from 'react'; import { LockOutlined, UserOutlined } from '@ant-design/icons'; -import { Button, Form, Input } from 'antd'; +import { Button, Checkbox, Form, Input } from 'antd'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -11,16 +11,46 @@ import { Head } from '@/components/head.tsx'; import { Tips } from './tips.tsx'; +const REMEMBER_KEY = 'nano-kvm-remember'; + +interface SavedCredentials { + username: string; + password: string; + autoLogin: boolean; +} + export const Login = (): ReactElement => { const navigate = useNavigate(); const { t } = useTranslation(); + const [form] = Form.useForm(); const [isLoading, setIsloading] = useState(false); const [msg, setMsg] = useState(''); + const [rememberChecked, setRememberChecked] = useState(false); useEffect(() => { if (existToken()) { navigate('/', { replace: true }); + return; + } + + try { + const saved = localStorage.getItem(REMEMBER_KEY); + if (saved) { + const creds: SavedCredentials = JSON.parse(saved); + form.setFieldsValue({ + username: creds.username, + password: creds.password, + remember: true, + autoLogin: creds.autoLogin + }); + setRememberChecked(true); + if (creds.autoLogin) { + doLogin({ username: creds.username, password: creds.password, remember: true, autoLogin: true }); + } + } + } catch { + localStorage.removeItem(REMEMBER_KEY); } }, []); @@ -30,7 +60,7 @@ export const Login = (): ReactElement => { } }, [msg]); - function login(values: any) { + function doLogin(values: any) { if (isLoading) return; setIsloading(true); @@ -50,6 +80,19 @@ export const Login = (): ReactElement => { return; } + if (values.remember) { + localStorage.setItem( + REMEMBER_KEY, + JSON.stringify({ + username: values.username, + password: values.password, + autoLogin: values.autoLogin || false + } as SavedCredentials) + ); + } else { + localStorage.removeItem(REMEMBER_KEY); + } + setMsg(''); setToken(rsp.data.token); @@ -70,9 +113,10 @@ export const Login = (): ReactElement => {
{ /> +
+ + { + setRememberChecked(e.target.checked); + if (!e.target.checked) { + form.setFieldValue('autoLogin', false); + } + }} + > + {t('auth.rememberPassword')} + + + + {t('auth.autoLogin')} + +
+
{msg}
diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index 2fccf47d..8b83febb 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, useState } from 'react'; import { Divider } from 'antd'; import clsx from 'clsx'; import { useAtomValue } from 'jotai'; @@ -8,6 +8,7 @@ import Draggable, { DraggableData, DraggableEvent } from 'react-draggable'; import { menuDisabledItemsAtom } from '@/jotai/settings.ts'; import { useMenuBounds } from '@/hooks/useMenuBounds.ts'; import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts'; +import { useMenuSnap, SNAP_PEEK, SNAP_HOVER_ZONE } from '@/hooks/useMenuSnap.ts'; import { DownloadImage } from './download.tsx'; import { Fullscreen } from './fullscreen'; @@ -26,6 +27,11 @@ import { Wol } from './wol'; export const Menu = () => { const nodeRef = useRef(null); + // Track the last drag position so we can restore it after unsnapping + const savedPosRef = useRef({ x: 0, y: 0 }); + // Incrementing this key forces the Draggable to re-mount at savedPosRef + const [unSnapKey, setUnSnapKey] = useState(0); + const menuDisabledItems = useAtomValue(menuDisabledItemsAtom); const { @@ -38,23 +44,279 @@ export const Menu = () => { } = useMenuVisibility(); const menuBounds = useMenuBounds(nodeRef, isMenuExpanded); + const { snapState, isSnapHovered, onDragStop, setSnapHovered, clearSnap } = useMenuSnap(); + + const isSnapped = snapState.edge !== null; + const edge = snapState.edge; + + // Ref to measure the popped-out panel's actual rendered size + const snapPanelRef = useRef(null); + // Clamped normalised offset so the panel never goes off-screen + const [clampedOffset, setClampedOffset] = useState(snapState.offset); + + useEffect(() => { + if (!isSnapped) return; + const el = snapPanelRef.current; + if (!el) return; - function onDragStop(_e: DraggableEvent, data: DraggableData) { + const isHorizontal = edge === 'top' || edge === 'bottom'; + const vw = window.innerWidth; + const vh = window.innerHeight; + + if (isHorizontal) { + const halfW = el.offsetWidth / 2; + const raw = snapState.offset * vw; + const clamped = Math.max(halfW + 4, Math.min(vw - halfW - 4, raw)); + setClampedOffset(clamped / vw); + } else { + const halfH = el.offsetHeight / 2; + const raw = snapState.offset * vh; + const clamped = Math.max(halfH + 4, Math.min(vh - halfH - 4, raw)); + setClampedOffset(clamped / vh); + } + }, [isSnapped, edge, snapState.offset, isSnapHovered]); + + function handleDragStop(_e: DraggableEvent, data: DraggableData) { + // Always save position so we can restore it on unsnap + savedPosRef.current = { x: data.x, y: data.y }; if (data.x === 0 && data.y === 0) return; + onDragStop(nodeRef); handleMoved(); } + // Clear snap and re-mount Draggable at the last known position (near the snap edge) + function handleClearSnap() { + clearSnap(); + setUnSnapKey((k) => k + 1); + } + + // Handles both click and drag on the grip while snapped. + // Moving >= 25px = drag to release; mouseup without movement = click to release. + function startGripDrag(e: React.MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + const startX = e.clientX; + const startY = e.clientY; + let unsnapped = false; + + function onMove(ev: MouseEvent) { + if (unsnapped) return; + const dx = ev.clientX - startX; + const dy = ev.clientY - startY; + if (Math.sqrt(dx * dx + dy * dy) > 25) { + unsnapped = true; + cleanup(); + handleClearSnap(); + } + } + + function onUp() { + cleanup(); + if (!unsnapped) { + handleClearSnap(); + } + } + + function cleanup() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + } + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + function isEnabled(item: string) { return !menuDisabledItems.includes(item); } + // Shared menu items renderer + function renderMenuItems(isHorizontal: boolean) { + const divider = ( + + ); + return ( + <> + + + + {divider} + + {isEnabled('image') && } + {isEnabled('download') && } + {isEnabled('script') &&