diff --git a/ui/src/components/Video/VideoElement.tsx b/ui/src/components/Video/VideoElement.tsx index b6b59d2..493af84 100644 --- a/ui/src/components/Video/VideoElement.tsx +++ b/ui/src/components/Video/VideoElement.tsx @@ -13,9 +13,10 @@ export const VideoElement = forwardRef( ({ onPlaying, style, className }, ref) => { const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled); const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled); + const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard); const handleClick = () => { - if (isMobile && !isVirtualKeyboardEnabled) { + if (isMobile && allowTapToOpenVirtualKeyboard && !isVirtualKeyboardEnabled) { setVirtualKeyboardEnabled(true); } }; @@ -46,4 +47,4 @@ export const VideoElement = forwardRef( } ); -VideoElement.displayName = "VideoElement"; \ No newline at end of file +VideoElement.displayName = "VideoElement"; diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index 234237a..70200cc 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -278,9 +278,10 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled); const isVirtualKeyboardEnabled = useHidStore(state => state.isVirtualKeyboardEnabled); + const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard); const handleClick = () => { - if (isMobile && !isVirtualKeyboardEnabled) { + if (isMobile && allowTapToOpenVirtualKeyboard && !isVirtualKeyboardEnabled) { setVirtualKeyboardEnabled(true); } }; diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index ef84bc4..3ba48ed 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -600,6 +600,8 @@ export interface HidState { isVirtualKeyboardEnabled: boolean; setVirtualKeyboardEnabled: (enabled: boolean) => void; + allowTapToOpenVirtualKeyboard: boolean; + setAllowTapToOpenVirtualKeyboard: (enabled: boolean) => void; isPasteModeEnabled: boolean; setPasteModeEnabled: (enabled: boolean) => void; @@ -665,6 +667,8 @@ export const useHidStore = create((set, get) => ({ isVirtualKeyboardEnabled: false, setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }), + allowTapToOpenVirtualKeyboard: true, + setAllowTapToOpenVirtualKeyboard: enabled => set({ allowTapToOpenVirtualKeyboard: enabled }), isPasteModeEnabled: false, setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }), diff --git a/ui/src/layout/core/desktop/DesktopMobile.tsx b/ui/src/layout/core/desktop/DesktopMobile.tsx index 69bea89..2da62fa 100644 --- a/ui/src/layout/core/desktop/DesktopMobile.tsx +++ b/ui/src/layout/core/desktop/DesktopMobile.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { BsMouseFill, BsLockFill, BsUnlockFill } from "react-icons/bs"; +import { BsKeyboardFill, BsLockFill, BsUnlockFill } from "react-icons/bs"; import { useReactAt } from "i18n-auto-extractor/react"; import VirtualKeyboard from "@components/VirtualKeyboard"; @@ -41,7 +41,47 @@ import VirtualMediaSource from "@/layout/components_side/VirtualMediaSource"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import OcrOverlay from "@components/OcrOverlay"; +const GestureIcon = ({ className = "h-4 w-4" }: { className?: string }) => ( + +); + +const ResetViewIcon = ({ className = "h-4 w-4" }: { className?: string }) => ( + +); + +const MouseStickIcon = ({ className = "h-4 w-4" }: { className?: string }) => ( + +); + +const FourWayMoveIcon = ({ className = "h-5 w-5" }: { className?: string }) => ( + +); + export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number }) { + const joystickSpeedLevels = [1.4, 1.05, 0.7]; const { $at } = useReactAt(); const { isDark } = useTheme(); const videoElm = useRef(null); @@ -57,50 +97,119 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number const videoStream = useVideoStream(videoElm as React.RefObject, audioElm as React.RefObject); const pointerLock = usePointerLock(videoElm as React.RefObject); useFullscreen(videoElm as React.RefObject, pointerLock, isFullscreen); - const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject); + const [isTouchGestureEnabled, setIsTouchGestureEnabled] = useState(true); + const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject, isTouchGestureEnabled); const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject); const keyboardEvents = useKeyboardEvents(pasteCaptureRef as React.RefObject, isReinitializingGadget); const [showVirtualMouseButtons, setShowVirtualMouseButtons] = useState(false); + const [showVirtualJoystick, setShowVirtualJoystick] = useState(false); + const [joystickVector, setJoystickVector] = useState({ x: 0, y: 0 }); + const [joystickSensitivity, setJoystickSensitivity] = useState(1); + const [joystickPos, setJoystickPos] = useState({ x: 16, y: 24 }); const [lockedButtons, setLockedButtons] = useState(0); const mouseEvents = useMouseEvents(videoElm as React.RefObject, pointerLock, touchZoom, showVirtualMouseButtons, lockedButtons); const overlays = useVideoOverlays(videoStream, pointerLock, videoEffects); const forceHttp = useSettingsStore(state => state.forceHttp); + const mouseMode = useSettingsStore(state => state.mouseMode); const mouseX = useMouseStore(state => state.mouseX); const mouseY = useMouseStore(state => state.mouseY); + const allowTapToOpenVirtualKeyboard = useHidStore(state => state.allowTapToOpenVirtualKeyboard); + const setAllowTapToOpenVirtualKeyboard = useHidStore(state => state.setAllowTapToOpenVirtualKeyboard); const [send] = useJsonRpc(); const [leftBtnPos, setLeftBtnPos] = useState({ x: 40, y: 40 }); const [rightBtnPos, setRightBtnPos] = useState({ x: 120, y: 40 }); - const [leftLockPos, setLeftLockPos] = useState({ x: 40, y: 110 }); - const [rightLockPos, setRightLockPos] = useState({ x: 120, y: 110 }); + const [wheelPos, setWheelPos] = useState({ x: 184, y: 140 }); - const [draggingBtn, setDraggingBtn] = useState<"left" | "right" | "leftLock" | "rightLock" | null>(null); + const [draggingBtn, setDraggingBtn] = useState<"left" | "right" | "wheel" | null>(null); const dragOffset = useRef({ x: 0, y: 0 }); + const joystickAreaRef = useRef(null); + const joystickPointerIdRef = useRef(null); + const joystickVectorRef = useRef({ x: 0, y: 0 }); + const joystickFrameRef = useRef(null); + const joystickLastTsRef = useRef(null); + const joystickMovePointerIdRef = useRef(null); + const joystickMoveHoldTimerRef = useRef(null); + const joystickMoveEnabledRef = useRef(false); const activeButtonsRef = useRef(0); useEffect(() => { if (isFullscreen) { setShowVirtualMouseButtons(false); + setShowVirtualJoystick(false); } }, [isFullscreen]); + useEffect(() => { + joystickVectorRef.current = joystickVector; + }, [joystickVector]); + + useEffect(() => { + if (!showVirtualJoystick) { + joystickPointerIdRef.current = null; + joystickMovePointerIdRef.current = null; + if (joystickMoveHoldTimerRef.current !== null) { + window.clearTimeout(joystickMoveHoldTimerRef.current); + joystickMoveHoldTimerRef.current = null; + } + joystickMoveEnabledRef.current = false; + joystickLastTsRef.current = null; + setJoystickVector({ x: 0, y: 0 }); + if (joystickFrameRef.current !== null) { + cancelAnimationFrame(joystickFrameRef.current); + joystickFrameRef.current = null; + } + return; + } + + const tick = (timestamp: number) => { + const prevTs = joystickLastTsRef.current ?? timestamp; + joystickLastTsRef.current = timestamp; + const frameScale = Math.min(2, Math.max(0.5, (timestamp - prevTs) / 16.67)); + const vector = joystickVectorRef.current; + const container = containerRef.current; + const containerWidth = container?.clientWidth ?? 1280; + // Reduce sensitivity on small screens to avoid over-shooting. + const resolutionFactor = Math.max(0.35, Math.min(1, containerWidth / 1280)); + const speed = 12 * resolutionFactor * joystickSensitivity; + const dx = Math.round(vector.x * speed * frameScale); + const dy = Math.round(vector.y * speed * frameScale); + if (dx !== 0 || dy !== 0) { + mouseEvents.sendVirtualRelativeMovement(dx, dy, 0); + } + joystickFrameRef.current = requestAnimationFrame(tick); + }; + + joystickFrameRef.current = requestAnimationFrame(tick); + return () => { + if (joystickFrameRef.current !== null) { + cancelAnimationFrame(joystickFrameRef.current); + joystickFrameRef.current = null; + } + joystickLastTsRef.current = null; + }; + }, [showVirtualJoystick, mouseEvents, joystickSensitivity]); + const updateButtons = (mask: number, isDown: boolean) => { if (isReinitializingGadget) return; let newButtons = activeButtonsRef.current; if (isDown) { newButtons |= mask; + } else if (lockedButtons & mask) { + // Keep pressed while lock is enabled. + newButtons |= mask; } else { newButtons &= ~mask; } - - if (!isDown && (lockedButtons & mask)) { - setLockedButtons(prev => prev & ~mask); - } activeButtonsRef.current = newButtons; - send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons }); + if (mouseMode === "relative") { + mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons); + } else { + send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons }); + } }; const toggleLock = (mask: number) => { @@ -117,10 +226,14 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number } activeButtonsRef.current = newButtons; - send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons }); + if (mouseMode === "relative") { + mouseEvents.sendVirtualRelativeMovement(0, 0, newButtons); + } else { + send("absMouseReport", { x: mouseX, y: mouseY, buttons: newButtons }); + } }; - const handlePointerDown = (e: React.PointerEvent, type: "left" | "right" | "leftLock" | "rightLock") => { + const handlePointerDown = (e: React.PointerEvent, type: "left" | "right" | "wheel") => { const target = e.currentTarget; const rect = target.getBoundingClientRect(); dragOffset.current = { @@ -138,26 +251,121 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number const containerRect = container.getBoundingClientRect(); const x = e.clientX - containerRect.left - dragOffset.current.x; const y = e.clientY - containerRect.top - dragOffset.current.y; - const clampedX = Math.max(0, Math.min(containerRect.width - 56, x)); - const clampedY = Math.max(0, Math.min(containerRect.height - 56, y)); + const dragWidth = draggingBtn === "wheel" ? 32 : 56; + const dragHeight = draggingBtn === "wheel" ? 68 : 56; + const clampedX = Math.max(0, Math.min(containerRect.width - dragWidth, x)); + const clampedY = Math.max(0, Math.min(containerRect.height - dragHeight, y)); if (draggingBtn === "left") { setLeftBtnPos({ x: clampedX, y: clampedY }); } else if (draggingBtn === "right") { setRightBtnPos({ x: clampedX, y: clampedY }); - } else if (draggingBtn === "leftLock") { - setLeftLockPos({ x: clampedX, y: clampedY }); - } else if (draggingBtn === "rightLock") { - setRightLockPos({ x: clampedX, y: clampedY }); + } else if (draggingBtn === "wheel") { + setWheelPos({ x: clampedX, y: clampedY }); } }; - const handlePointerUp = (e: React.PointerEvent, type: "left" | "right" | "leftLock" | "rightLock") => { + const handlePointerUp = (e: React.PointerEvent, type: "left" | "right" | "wheel") => { const wasDragging = draggingBtn === type; setDraggingBtn(null); (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); if (!wasDragging) return; }; + const updateJoystickVector = (clientX: number, clientY: number) => { + const joystickElm = joystickAreaRef.current; + if (!joystickElm) return; + const rect = joystickElm.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const rawX = clientX - centerX; + const rawY = clientY - centerY; + const maxRadius = 32; + const length = Math.hypot(rawX, rawY); + if (!length || length <= maxRadius) { + setJoystickVector({ x: rawX / maxRadius, y: rawY / maxRadius }); + return; + } + const scale = maxRadius / length; + setJoystickVector({ x: (rawX * scale) / maxRadius, y: (rawY * scale) / maxRadius }); + }; + + const handleJoystickPointerDown = (e: React.PointerEvent) => { + if (joystickMovePointerIdRef.current !== null) return; + e.preventDefault(); + joystickPointerIdRef.current = e.pointerId; + e.currentTarget.setPointerCapture(e.pointerId); + updateJoystickVector(e.clientX, e.clientY); + }; + + const handleJoystickPointerMove = (e: React.PointerEvent) => { + if (joystickPointerIdRef.current !== e.pointerId) return; + e.preventDefault(); + updateJoystickVector(e.clientX, e.clientY); + }; + + const handleJoystickPointerUp = (e: React.PointerEvent) => { + if (joystickPointerIdRef.current !== e.pointerId) return; + joystickPointerIdRef.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + setJoystickVector({ x: 0, y: 0 }); + }; + + const handleJoystickMoveStart = (e: React.PointerEvent) => { + joystickMovePointerIdRef.current = e.pointerId; + joystickMoveEnabledRef.current = false; + if (joystickMoveHoldTimerRef.current !== null) { + window.clearTimeout(joystickMoveHoldTimerRef.current); + } + joystickMoveHoldTimerRef.current = window.setTimeout(() => { + joystickMoveEnabledRef.current = true; + }, 350); + e.currentTarget.setPointerCapture(e.pointerId); + e.preventDefault(); + e.stopPropagation(); + }; + + const handleJoystickMove = (e: React.PointerEvent) => { + if (joystickMovePointerIdRef.current !== e.pointerId) return; + if (!joystickMoveEnabledRef.current) return; + const container = containerRef.current; + if (!container) return; + const containerRect = container.getBoundingClientRect(); + const joystickSize = 80; + const nextLeft = e.clientX - containerRect.left - joystickSize / 2; + const nextTop = e.clientY - containerRect.top - joystickSize / 2; + const clampedLeft = Math.max(0, Math.min(containerRect.width - joystickSize, nextLeft)); + const clampedTop = Math.max(0, Math.min(containerRect.height - joystickSize, nextTop)); + const nextBottom = containerRect.height - joystickSize - clampedTop; + setJoystickPos({ x: clampedLeft, y: nextBottom }); + e.preventDefault(); + e.stopPropagation(); + }; + + const handleJoystickMoveEnd = (e: React.PointerEvent) => { + if (joystickMovePointerIdRef.current !== e.pointerId) return; + if (joystickMoveHoldTimerRef.current !== null) { + window.clearTimeout(joystickMoveHoldTimerRef.current); + joystickMoveHoldTimerRef.current = null; + } + joystickMoveEnabledRef.current = false; + joystickMovePointerIdRef.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + e.preventDefault(); + e.stopPropagation(); + }; + + const isMouseControlEnabled = showVirtualJoystick || showVirtualMouseButtons; + const joystickSpeedIndex = joystickSpeedLevels.reduce((bestIndex, value, index, arr) => { + const bestDistance = Math.abs(arr[bestIndex] - joystickSensitivity); + const currentDistance = Math.abs(value - joystickSensitivity); + return currentDistance < bestDistance ? index : bestIndex; + }, 0); + const toggleMouseControl = () => { + const nextEnabled = !isMouseControlEnabled; + setShowVirtualJoystick(nextEnabled); + setShowVirtualMouseButtons(nextEnabled); + }; + useEffect(() => { const keyboardCleanup = keyboardEvents.setupKeyboardEvents(); const videoCleanup = videoStream.setupVideoEventListeners(); @@ -303,107 +511,292 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number className="pointer-events-none absolute inset-0" onPointerMove={handlePointerMove} > -
setShowVirtualMouseButtons(prev => !prev)} - > - +
+
setIsTouchGestureEnabled(prev => !prev)} + > + +
+
+ +
+
setAllowTapToOpenVirtualKeyboard(!allowTapToOpenVirtualKeyboard)} + > + +
+
touchZoom.resetTransform()} + > + +
{showVirtualMouseButtons && ( <>
{ - handlePointerDown(e, "left"); + onPointerDown={() => { updateButtons(1, true); }} - onPointerUp={e => { - handlePointerUp(e, "left"); + onPointerUp={() => { updateButtons(1, false); }} > L +
{ + e.stopPropagation(); + handlePointerDown(e, "left"); + }} + onPointerMove={e => { + e.stopPropagation(); + handlePointerMove(e); + }} + onPointerUp={e => { + e.stopPropagation(); + handlePointerUp(e, "left"); + }} + > + +
+
{ + e.stopPropagation(); + }} + onClick={e => { + e.stopPropagation(); + toggleLock(1); + }} + > + {(lockedButtons & 1) ? : } +
{ - handlePointerDown(e, "leftLock"); - }} - onPointerUp={e => { - handlePointerUp(e, "leftLock"); - }} - onClick={() => toggleLock(1)} - > - {(lockedButtons & 1) ? : } L -
- -
{ - handlePointerDown(e, "right"); + onPointerDown={() => { updateButtons(2, true); }} - onPointerUp={e => { - handlePointerUp(e, "right"); + onPointerUp={() => { updateButtons(2, false); }} > R +
{ + e.stopPropagation(); + handlePointerDown(e, "right"); + }} + onPointerMove={e => { + e.stopPropagation(); + handlePointerMove(e); + }} + onPointerUp={e => { + e.stopPropagation(); + handlePointerUp(e, "right"); + }} + > + +
+
{ + e.stopPropagation(); + }} + onClick={e => { + e.stopPropagation(); + toggleLock(2); + }} + > + {(lockedButtons & 2) ? : } +
{ - handlePointerDown(e, "rightLock"); - }} - onPointerUp={e => { - handlePointerUp(e, "rightLock"); - }} - onClick={() => toggleLock(2)} > - {(lockedButtons & 2) ? : } R +
send("wheelReport", { wheelY: 1 })} + > + ▲ +
+
{ + e.stopPropagation(); + handlePointerDown(e, "wheel"); + }} + onPointerMove={e => { + e.stopPropagation(); + handlePointerMove(e); + }} + onPointerUp={e => { + e.stopPropagation(); + handlePointerUp(e, "wheel"); + }} + > + +
+
send("wheelReport", { wheelY: -1 })} + > + ▼ +
)} + {showVirtualJoystick && ( +
+
+
+ +
+
+
+
+ {joystickSpeedLevels.map((level, index) => ( +
+
+
+
+
+ )}
diff --git a/ui/src/layout/core/desktop/hooks/useMouseEvents.ts b/ui/src/layout/core/desktop/hooks/useMouseEvents.ts index 0b8d54a..c4bba93 100644 --- a/ui/src/layout/core/desktop/hooks/useMouseEvents.ts +++ b/ui/src/layout/core/desktop/hooks/useMouseEvents.ts @@ -25,12 +25,13 @@ export const useMouseEvents = ( const { setMousePosition, setMouseMove } = useMouseStore(); const { width: videoWidth, height: videoHeight } = useVideoStore(); const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget); + const touchDragActiveRef = useRef(false); const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos); const sendRelMouseMovement = useCallback( - (x: number, y: number, buttons: number) => { - if (settings.mouseMode !== "relative") return; + (x: number, y: number, buttons: number, force = false) => { + if (!force && settings.mouseMode !== "relative") return; // Don't send mouse events while reinitializing gadget if (isReinitializingGadget) return; send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons }); @@ -50,6 +51,13 @@ export const useMouseEvents = ( [send, setMousePosition, settings.mouseMode, isReinitializingGadget], ); + const sendVirtualRelativeMovement = useCallback( + (x: number, y: number, buttons = 0) => { + sendRelMouseMovement(x, y, buttons, true); + }, + [sendRelMouseMovement], + ); + const relMouseMoveHandler = useCallback( (e: MouseEvent) => { const pt = (e as unknown as PointerEvent).pointerType as unknown as string; @@ -76,11 +84,17 @@ export const useMouseEvents = ( const absMouseMoveHandler = useCallback( (e: MouseEvent) => { const pt = (e as unknown as PointerEvent).pointerType as unknown as string; + const pointerEvent = e as unknown as PointerEvent; + const eventType = pointerEvent.type; if (pt === "touch") { if (touchZoom) { - const touchCount = touchZoom.activeTouchPointers.current.size; - const eventType = (e as unknown as PointerEvent).type; - if (touchCount >= 2 && eventType !== "pointerup") return; + const touchCount = touchZoom.activeTouchPointers.current.size; + if (touchCount >= 2) { + if (eventType === "pointerup" || eventType === "pointercancel") { + touchDragActiveRef.current = false; + } + return; + } } } @@ -135,28 +149,20 @@ export const useMouseEvents = ( let buttons = e.buttons; if (pt === "touch") { - const touchCount = touchZoom ? touchZoom.activeTouchPointers.current.size : 1; - const pointerEvent = e as unknown as PointerEvent; - const eventType = pointerEvent.type; - - if (eventType === "pointerup") { - if (touchCount >= 2 || disableTouchClick) { - buttons = 0; - } else { - buttons = 1; - } - } else { + if (eventType === "pointerdown") { + touchDragActiveRef.current = !disableTouchClick; + } + if (eventType === "pointerup" || eventType === "pointercancel") { buttons = 0; + touchDragActiveRef.current = false; + } else { + buttons = touchDragActiveRef.current ? 1 : 0; } } buttons |= externalButtons; sendAbsMouseMovement(x, y, buttons); - - if (pt === "touch" && buttons !== externalButtons && (e as unknown as PointerEvent).type === "pointerup") { - sendAbsMouseMovement(x, y, externalButtons); - } }, [settings.mouseMode, videoElm, videoWidth, videoHeight, sendAbsMouseMovement, touchZoom, disableTouchClick, externalButtons], ); @@ -216,8 +222,10 @@ export const useMouseEvents = ( }; videoElmRefValue.addEventListener("mousemove", eventHandler, { signal }); + videoElmRefValue.addEventListener("pointermove", eventHandler, { signal }); videoElmRefValue.addEventListener("pointerdown", eventHandler, { signal }); videoElmRefValue.addEventListener("pointerup", eventHandler, { signal }); + videoElmRefValue.addEventListener("pointercancel", eventHandler, { signal }); videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal, passive: true, @@ -255,5 +263,6 @@ export const useMouseEvents = ( return { setupMouseEvents, + sendVirtualRelativeMovement, }; }; diff --git a/ui/src/layout/core/desktop/hooks/useTouchZoom.ts b/ui/src/layout/core/desktop/hooks/useTouchZoom.ts index 6f530b8..5c21c7a 100644 --- a/ui/src/layout/core/desktop/hooks/useTouchZoom.ts +++ b/ui/src/layout/core/desktop/hooks/useTouchZoom.ts @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from "react"; export const useTouchZoom = ( - containerRef: React.RefObject + containerRef: React.RefObject, + gestureEnabled = true, ) => { const [mobileScale, setMobileScale] = useState(1); const [mobileTx, setMobileTx] = useState(0); @@ -17,67 +18,83 @@ export const useTouchZoom = ( if (!el) return; const abortController = new AbortController(); const signal = abortController.signal; + const isPointInVideo = (x: number, y: number) => { + const video = el.querySelector("video") as HTMLVideoElement | null; + if (!video) return false; + const vRect = video.getBoundingClientRect(); + return x >= vRect.left && x <= vRect.right && y >= vRect.top && y <= vRect.bottom; + }; const onPointerDown = (e: PointerEvent) => { if (e.pointerType !== "touch") return; activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (!gestureEnabled) return; + let shouldHandleLocalGesture = false; + if (activeTouchPointers.current.size === 1) { const now = Date.now(); - let isInVideo = false; - const video = el.querySelector("video") as HTMLVideoElement | null; - if (video) { - const vRect = video.getBoundingClientRect(); - if ( - e.clientX >= vRect.left && - e.clientX <= vRect.right && - e.clientY >= vRect.top && - e.clientY <= vRect.bottom - ) { - isInVideo = true; - } - } + const isInVideo = isPointInVideo(e.clientX, e.clientY); if (!isInVideo) { if (now - lastTapAt.current < 300) { setMobileScale(1); setMobileTx(0); setMobileTy(0); } + shouldHandleLocalGesture = true; } lastTapAt.current = now; - lastPanPoint.current = { x: e.clientX, y: e.clientY }; + if (mobileScale > 1) { + lastPanPoint.current = { x: e.clientX, y: e.clientY }; + shouldHandleLocalGesture = true; + } else { + lastPanPoint.current = null; + } } else if (activeTouchPointers.current.size === 2) { const pts = Array.from(activeTouchPointers.current.values()); const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); initialPinchDistance.current = d; initialPinchScale.current = mobileScale; + shouldHandleLocalGesture = true; + } + + if (shouldHandleLocalGesture) { + e.preventDefault(); + e.stopPropagation(); } - e.preventDefault(); - e.stopPropagation(); }; const onPointerMove = (e: PointerEvent) => { if (e.pointerType !== "touch") return; const prev = activeTouchPointers.current.get(e.pointerId); activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (!gestureEnabled) return; const pts = Array.from(activeTouchPointers.current.values()); + let shouldHandleLocalGesture = false; + if (pts.length === 2 && initialPinchDistance.current) { const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); const factor = d / initialPinchDistance.current; const next = Math.max(1, Math.min(4, initialPinchScale.current * factor)); setMobileScale(next); - } else if (pts.length === 1 && lastPanPoint.current && prev) { + shouldHandleLocalGesture = true; + } else if (pts.length === 1 && mobileScale > 1 && lastPanPoint.current && prev) { const dx = e.clientX - lastPanPoint.current.x; const dy = e.clientY - lastPanPoint.current.y; lastPanPoint.current = { x: e.clientX, y: e.clientY }; setMobileTx(v => v + dx); setMobileTy(v => v + dy); + shouldHandleLocalGesture = true; + } + + if (shouldHandleLocalGesture) { + e.preventDefault(); + e.stopPropagation(); } - e.preventDefault(); - e.stopPropagation(); }; const onPointerUp = (e: PointerEvent) => { if (e.pointerType !== "touch") return; + const wasHandlingLocalGesture = gestureEnabled && (initialPinchDistance.current !== null || mobileScale > 1); activeTouchPointers.current.delete(e.pointerId); if (activeTouchPointers.current.size < 2) { initialPinchDistance.current = null; @@ -85,8 +102,11 @@ export const useTouchZoom = ( if (activeTouchPointers.current.size === 0) { lastPanPoint.current = null; } - e.preventDefault(); - e.stopPropagation(); + + if (wasHandlingLocalGesture) { + e.preventDefault(); + e.stopPropagation(); + } }; el.addEventListener("pointerdown", onPointerDown, { signal }); @@ -95,7 +115,15 @@ export const useTouchZoom = ( el.addEventListener("pointercancel", onPointerUp, { signal }); return () => abortController.abort(); - }, [mobileScale, containerRef]); + }, [mobileScale, containerRef, gestureEnabled]); + + const resetTransform = () => { + initialPinchDistance.current = null; + lastPanPoint.current = null; + setMobileScale(1); + setMobileTx(0); + setMobileTy(0); + }; useEffect(() => { const container = containerRef.current; @@ -115,5 +143,6 @@ export const useTouchZoom = ( mobileTy, activeTouchPointers, lastPanPoint, + resetTransform, }; };