From 01c7fc1c639babaef923532d3f57a4c6647d5013 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Tue, 21 Apr 2026 10:59:21 +0200 Subject: [PATCH] Fix: Web remote desktop double-tap drag functionality --- server/2015Remote/WebPage.h | 277 +++++++++++++++++++++--------------- 1 file changed, 161 insertions(+), 116 deletions(-) diff --git a/server/2015Remote/WebPage.h b/server/2015Remote/WebPage.h index e2459f5..c9d7bc2 100644 --- a/server/2015Remote/WebPage.h +++ b/server/2015Remote/WebPage.h @@ -1494,13 +1494,34 @@ inline std::string GetWebPageHTML() { // Mobile touch handling (touchpad mode - like Microsoft Remote Desktop) // Cursor position is separate from finger position let cursorState = { x: 0, y: 0, initialized: false }; // Remote cursor position + + // Touch state machine - delayed mousedown approach + // Key insight: Don't send mousedown on first touchstart + // This allows clean separation of "move cursor" vs "click/drag" + // + // States: + // IDLE: No touch + // FIRST_DOWN: First finger down, waiting to see intent + // MOVING: User is moving cursor (no mouse buttons involved) + // WAITING_SECOND: First tap done, waiting for possible second tap + // SECOND_DOWN: Second tap down, waiting to see drag or dblclick + // DRAGGING: Confirmed drag operation (mousedown sent) + const T_IDLE = 0; + const T_FIRST_DOWN = 1; + const T_MOVING = 2; + const T_WAITING_SECOND = 3; + const T_SECOND_DOWN = 4; + const T_DRAGGING = 5; + let touchState = { - lastTap: 0, longPressTimer: null, isDragging: false, - lastX: 0, lastY: 0, // Last touch screen position (for delta calculation) + state: T_IDLE, + longPressTimer: null, + lastX: 0, lastY: 0, touchCount: 0, - startX: 0, startY: 0, // Touch start position (to detect tap vs drag) - moved: false, // Did finger move significantly? - dragHoldTimer: null // Timer for double-tap-hold drag detection + startX: 0, startY: 0, + startTime: 0, + moved: false, + secondTapTimer: null // Timer for waiting second tap }; const touchIndicator = document.getElementById('touch-indicator'); const mobileKeyboard = document.getElementById('mobile-keyboard'); @@ -1585,92 +1606,104 @@ inline std::string GetWebPageHTML() { html += R"HTML( canvas.addEventListener('touchstart', function(e) { e.preventDefault(); - e.stopPropagation(); // Prevent exiting fullscreen + e.stopPropagation(); touchState.touchCount = e.touches.length; if (e.touches.length === 2) { - // Two finger touch - could be pinch zoom or scroll + // Two finger - pinch zoom or scroll zoomState.isPinching = true; const initialDist = getPinchDistance(e.touches); - zoomState.initialPinchDist = initialDist; // For cumulative change detection - zoomState.lastPinchDist = initialDist; // For frame-by-frame zoom calculation - zoomState.hasZoomed = false; // Track if zoom occurred during this gesture + zoomState.initialPinchDist = initialDist; + zoomState.lastPinchDist = initialDist; + zoomState.hasZoomed = false; const center = getPinchCenter(e.touches); zoomState.pinchCenterX = center.x; zoomState.pinchCenterY = center.y; - zoomState.lastScrollY = center.y; // For scroll delta calculation + zoomState.lastScrollY = center.y; - // Calculate pinch center relative to canvas for transform-origin - // Only set origin when starting a new zoom (scale == 1) if (zoomState.scale === 1) { const rect = canvas.getBoundingClientRect(); const relX = (center.x - rect.left) / rect.width * 100; const relY = (center.y - rect.top) / rect.height * 100; - // Clamp to canvas bounds zoomState.originX = Math.max(0, Math.min(100, relX)); zoomState.originY = Math.max(0, Math.min(100, relY)); } - // Cancel any pending single-touch actions - if (touchState.longPressTimer) { - clearTimeout(touchState.longPressTimer); - touchState.longPressTimer = null; - } - if (touchState.dragHoldTimer) { - clearTimeout(touchState.dragHoldTimer); - touchState.dragHoldTimer = null; + // Clean up single-touch state + if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; } + if (touchState.secondTapTimer) { clearTimeout(touchState.secondTapTimer); touchState.secondTapTimer = null; } + // If we were dragging, end it + if (touchState.state === T_DRAGGING) { + sendMouse('up', Math.round(cursorState.x), Math.round(cursorState.y), 0); } + touchState.state = T_IDLE; return; } - // Single finger touch - touchpad mode + // Single finger touch initCursor(); const touch = e.touches[0]; - // Store screen coordinates for delta calculation + const oldStartX = touchState.startX; + const oldStartY = touchState.startY; touchState.startX = touch.clientX; touchState.startY = touch.clientY; touchState.lastX = touch.clientX; touchState.lastY = touch.clientY; - touchState.isDragging = false; touchState.moved = false; - // Initialize pan center for zoom pan detection + touchState.startTime = Date.now(); zoomState.pinchCenterX = touch.clientX; zoomState.pinchCenterY = touch.clientY; - // Clear any pending timers - if (touchState.dragHoldTimer) { - clearTimeout(touchState.dragHoldTimer); - touchState.dragHoldTimer = null; + if (!controlEnabled) { + touchState.state = T_FIRST_DOWN; + return; } - const now = Date.now(); - const timeSinceLastTap = now - touchState.lastTap; - const isDoubleTap = (timeSinceLastTap < 400); + if (touchState.state === T_WAITING_SECOND) { + // Second tap detected + if (touchState.secondTapTimer) { clearTimeout(touchState.secondTapTimer); touchState.secondTapTimer = null; } - if (isDoubleTap && controlEnabled) { - // Double-tap detected - wait to see if user holds (drag) or releases quickly (double-click) - // Microsoft RD style: tap-tap-hold = drag, tap-tap-release = double-click - touchState.dragHoldTimer = setTimeout(() => { - // Finger still down after 150ms = start drag - if (!touchState.moved) { - touchState.isDragging = true; - const x = Math.round(cursorState.x); - const y = Math.round(cursorState.y); - sendMouse('down', x, y, 0); - const overlay = document.getElementById('cursor-overlay'); - overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) hue-rotate(90deg)'; - console.log('[Drag] Started at', x, y); - } - touchState.dragHoldTimer = null; - }, 150); // 150ms delay before starting drag - touchState.lastTap = 0; // Reset to prevent triple-tap - } else if (controlEnabled) { - // Single tap - set up for potential double-tap - // Long press (500ms) for right click + // Check distance from first tap + const dx = touch.clientX - oldStartX; + const dy = touch.clientY - oldStartY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist > 50) { + // Too far - treat as new first tap, complete previous click first + clickAtCursor(0); + console.log('[Touch] Second tap too far (' + Math.round(dist) + 'px), new first tap'); + // Move cursor to new position + const rect = canvas.getBoundingClientRect(); + cursorState.x = Math.max(0, Math.min(canvas.width - 1, cursorState.x + dx * canvas.width / rect.width * 1.5)); + cursorState.y = Math.max(0, Math.min(canvas.height - 1, cursorState.y + dy * canvas.height / rect.height * 1.5)); + updateCursorOverlay(cursorState.x, cursorState.y); + touchState.state = T_FIRST_DOWN; + // Set up long press for right click + touchState.longPressTimer = setTimeout(() => { + if (!touchState.moved && touchState.state === T_FIRST_DOWN) { + clickAtCursor(2); + showTouchIndicator(touch.clientX, touch.clientY); + touchState.state = T_IDLE; + } + touchState.longPressTimer = null; + }, 500); + } else { + // Close enough - this is second tap for double-click or drag + touchState.state = T_SECOND_DOWN; + console.log('[Touch] Second tap - waiting for drag or dblclick'); + } + } else { + // First tap + if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; } + touchState.state = T_FIRST_DOWN; + console.log('[Touch] First tap'); + + // Long press for right click touchState.longPressTimer = setTimeout(() => { - if (!touchState.moved) { - clickAtCursor(2); // Right click + if (!touchState.moved && touchState.state === T_FIRST_DOWN) { + clickAtCursor(2); showTouchIndicator(touch.clientX, touch.clientY); + touchState.state = T_IDLE; } touchState.longPressTimer = null; }, 500); @@ -1743,7 +1776,7 @@ inline std::string GetWebPageHTML() { return; } - if (e.touches.length === 1 && zoomState.scale > 1 && !touchState.isDragging && !controlEnabled) { + if (e.touches.length === 1 && zoomState.scale > 1 && touchState.state !== T_DRAGGING && !controlEnabled) { // Pan when zoomed (only when control mode is OFF - view-only mode) const touch = e.touches[0]; const dx = touch.clientX - zoomState.pinchCenterX; @@ -1764,58 +1797,62 @@ inline std::string GetWebPageHTML() { return; } - // Single finger move - touchpad mode (relative cursor movement) + // Single finger move - state machine based const touch = e.touches[0]; const dx = touch.clientX - touchState.lastX; const dy = touch.clientY - touchState.lastY; - // Check if moved significantly (to distinguish tap from drag) - // Use higher threshold (20px) to allow for finger jitter during tap const totalDx = touch.clientX - touchState.startX; const totalDy = touch.clientY - touchState.startY; const totalDist = Math.sqrt(totalDx * totalDx + totalDy * totalDy); - if (totalDist > 20) { + + // Different thresholds for different states + const moveThreshold = (touchState.state === T_SECOND_DOWN) ? 10 : 20; + + if (totalDist > moveThreshold && !touchState.moved) { touchState.moved = true; - // Cancel long press if moving if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; } - // If dragHoldTimer is pending (double-tap detected), start drag immediately on movement - if (touchState.dragHoldTimer && !touchState.isDragging) { - clearTimeout(touchState.dragHoldTimer); - touchState.dragHoldTimer = null; - // Start drag now - touchState.isDragging = true; - const x = Math.round(cursorState.x); - const y = Math.round(cursorState.y); - sendMouse('down', x, y, 0); - const overlay = document.getElementById('cursor-overlay'); - overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) hue-rotate(90deg)'; - console.log('[Drag] Started early on movement at', x, y); - } } - // Convert screen delta to canvas delta - // getBoundingClientRect() always reflects current visual state (including CSS transform) - // This ensures correct calculation even during zoom transitions const rect = canvas.getBoundingClientRect(); const canvasDx = dx * canvas.width / rect.width; const canvasDy = dy * canvas.height / rect.height; - // Move cursor (touchpad mode) - if (touchState.isDragging) { - // Dragging: move cursor and send move events - moveCursorBy(canvasDx, canvasDy); - } else if (touchState.moved) { - // Just moving cursor (not dragging yet) + // State machine transitions and actions + if (touchState.state === T_FIRST_DOWN && touchState.moved) { + // First tap + move = pure cursor movement (no click involved) + touchState.state = T_MOVING; + console.log('[Touch] Moving cursor'); + } + + if (touchState.state === T_SECOND_DOWN && touchState.moved) { + // Second tap + move = START DRAG NOW + // Send mousedown at CURRENT position (after some movement) + // This prevents Windows from treating it as double-click + touchState.state = T_DRAGGING; + const x = Math.round(cursorState.x); + const y = Math.round(cursorState.y); + sendMouse('down', x, y, 0); + const overlay = document.getElementById('cursor-overlay'); + overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) hue-rotate(90deg)'; + console.log('[Touch] Drag started at', x, y); + } + + // Move cursor based on state + if (touchState.state === T_MOVING || touchState.state === T_DRAGGING) { moveCursorBy(canvasDx, canvasDy); } touchState.lastX = touch.clientX; touchState.lastY = touch.clientY; }, { passive: false }); +)HTML"; + // Part 15b - Touch end handler + html += R"HTML( canvas.addEventListener('touchend', function(e) { e.preventDefault(); e.stopPropagation(); // Prevent exiting fullscreen @@ -1849,45 +1886,53 @@ inline std::string GetWebPageHTML() { return; } - // Reset cursor style + // State machine based touchend const overlay = document.getElementById('cursor-overlay'); + const x = Math.round(cursorState.x); + const y = Math.round(cursorState.y); - if (touchState.isDragging) { - // End drag at cursor position - const x = Math.round(cursorState.x); - const y = Math.round(cursorState.y); - sendMouse('up', x, y, 0); - overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6))'; - console.log('[Drag] Ended at', x, y); - touchState.lastTap = 0; // Reset after drag - } else if (touchState.dragHoldTimer) { - // Double-tap but released before drag started = double click - clearTimeout(touchState.dragHoldTimer); - touchState.dragHoldTimer = null; - if (!touchState.moved) { - console.log('[Tap] Double click'); - dblClickAtCursor(); - } - touchState.lastTap = 0; - } else if (touchState.longPressTimer) { + if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; - // Tap = click at cursor position (touchpad mode) - if (!touchState.moved) { - const now = Date.now(); - // Visual feedback - flash cursor - overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) brightness(2)'; - setTimeout(() => overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6))', 150); - // Single tap = left click at cursor - console.log('[Tap] Single click'); - clickAtCursor(0); - touchState.lastTap = now; - } else { - touchState.lastTap = 0; - } } - touchState.isDragging = false; + if (touchState.state === T_DRAGGING) { + // End drag + sendMouse('up', x, y, 0); + overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6))'; + console.log('[Touch] Drag ended at', x, y); + touchState.state = T_IDLE; + } else if (touchState.state === T_SECOND_DOWN) { + // Second tap released without moving = double click + // Must send first click before dblclick for Windows to recognize + console.log('[Touch] Double click'); + clickAtCursor(0); // First click + dblClickAtCursor(); // Then double click + touchState.state = T_IDLE; + } else if (touchState.state === T_FIRST_DOWN && !touchState.moved) { + // First tap released without moving = single click + // Wait briefly for possible second tap + overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) brightness(2)'; + setTimeout(() => overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6))', 150); + console.log('[Touch] First tap done, waiting for second'); + touchState.state = T_WAITING_SECOND; + // Set timer: if no second tap, complete the single click + touchState.secondTapTimer = setTimeout(() => { + if (touchState.state === T_WAITING_SECOND) { + console.log('[Touch] Single click (no second tap)'); + clickAtCursor(0); + touchState.state = T_IDLE; + } + touchState.secondTapTimer = null; + }, 250); + } else if (touchState.state === T_MOVING) { + // Was just moving cursor, no action needed + console.log('[Touch] Cursor move done'); + touchState.state = T_IDLE; + } else { + touchState.state = T_IDLE; + } + touchState.moved = false; touchState.touchCount = e.touches.length; }, { passive: false });