Fix: Web remote desktop double-tap drag functionality

This commit is contained in:
yuanyuanxiang
2026-04-21 10:59:21 +02:00
parent 80f95a41b2
commit 01c7fc1c63

View File

@@ -1494,13 +1494,34 @@ inline std::string GetWebPageHTML() {
// Mobile touch handling (touchpad mode - like Microsoft Remote Desktop) // Mobile touch handling (touchpad mode - like Microsoft Remote Desktop)
// Cursor position is separate from finger position // Cursor position is separate from finger position
let cursorState = { x: 0, y: 0, initialized: false }; // Remote cursor 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 = { let touchState = {
lastTap: 0, longPressTimer: null, isDragging: false, state: T_IDLE,
lastX: 0, lastY: 0, // Last touch screen position (for delta calculation) longPressTimer: null,
lastX: 0, lastY: 0,
touchCount: 0, touchCount: 0,
startX: 0, startY: 0, // Touch start position (to detect tap vs drag) startX: 0, startY: 0,
moved: false, // Did finger move significantly? startTime: 0,
dragHoldTimer: null // Timer for double-tap-hold drag detection moved: false,
secondTapTimer: null // Timer for waiting second tap
}; };
const touchIndicator = document.getElementById('touch-indicator'); const touchIndicator = document.getElementById('touch-indicator');
const mobileKeyboard = document.getElementById('mobile-keyboard'); const mobileKeyboard = document.getElementById('mobile-keyboard');
@@ -1585,92 +1606,104 @@ inline std::string GetWebPageHTML() {
html += R"HTML( html += R"HTML(
canvas.addEventListener('touchstart', function(e) { canvas.addEventListener('touchstart', function(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent exiting fullscreen e.stopPropagation();
touchState.touchCount = e.touches.length; touchState.touchCount = e.touches.length;
if (e.touches.length === 2) { if (e.touches.length === 2) {
// Two finger touch - could be pinch zoom or scroll // Two finger - pinch zoom or scroll
zoomState.isPinching = true; zoomState.isPinching = true;
const initialDist = getPinchDistance(e.touches); const initialDist = getPinchDistance(e.touches);
zoomState.initialPinchDist = initialDist; // For cumulative change detection zoomState.initialPinchDist = initialDist;
zoomState.lastPinchDist = initialDist; // For frame-by-frame zoom calculation zoomState.lastPinchDist = initialDist;
zoomState.hasZoomed = false; // Track if zoom occurred during this gesture zoomState.hasZoomed = false;
const center = getPinchCenter(e.touches); const center = getPinchCenter(e.touches);
zoomState.pinchCenterX = center.x; zoomState.pinchCenterX = center.x;
zoomState.pinchCenterY = center.y; 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) { if (zoomState.scale === 1) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const relX = (center.x - rect.left) / rect.width * 100; const relX = (center.x - rect.left) / rect.width * 100;
const relY = (center.y - rect.top) / rect.height * 100; const relY = (center.y - rect.top) / rect.height * 100;
// Clamp to canvas bounds
zoomState.originX = Math.max(0, Math.min(100, relX)); zoomState.originX = Math.max(0, Math.min(100, relX));
zoomState.originY = Math.max(0, Math.min(100, relY)); zoomState.originY = Math.max(0, Math.min(100, relY));
} }
// Cancel any pending single-touch actions // Clean up single-touch state
if (touchState.longPressTimer) { if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; }
clearTimeout(touchState.longPressTimer); if (touchState.secondTapTimer) { clearTimeout(touchState.secondTapTimer); touchState.secondTapTimer = null; }
touchState.longPressTimer = null; // If we were dragging, end it
} if (touchState.state === T_DRAGGING) {
if (touchState.dragHoldTimer) { sendMouse('up', Math.round(cursorState.x), Math.round(cursorState.y), 0);
clearTimeout(touchState.dragHoldTimer);
touchState.dragHoldTimer = null;
} }
touchState.state = T_IDLE;
return; return;
} }
// Single finger touch - touchpad mode // Single finger touch
initCursor(); initCursor();
const touch = e.touches[0]; const touch = e.touches[0];
// Store screen coordinates for delta calculation const oldStartX = touchState.startX;
const oldStartY = touchState.startY;
touchState.startX = touch.clientX; touchState.startX = touch.clientX;
touchState.startY = touch.clientY; touchState.startY = touch.clientY;
touchState.lastX = touch.clientX; touchState.lastX = touch.clientX;
touchState.lastY = touch.clientY; touchState.lastY = touch.clientY;
touchState.isDragging = false;
touchState.moved = false; touchState.moved = false;
// Initialize pan center for zoom pan detection touchState.startTime = Date.now();
zoomState.pinchCenterX = touch.clientX; zoomState.pinchCenterX = touch.clientX;
zoomState.pinchCenterY = touch.clientY; zoomState.pinchCenterY = touch.clientY;
// Clear any pending timers if (!controlEnabled) {
if (touchState.dragHoldTimer) { touchState.state = T_FIRST_DOWN;
clearTimeout(touchState.dragHoldTimer); return;
touchState.dragHoldTimer = null;
} }
const now = Date.now(); if (touchState.state === T_WAITING_SECOND) {
const timeSinceLastTap = now - touchState.lastTap; // Second tap detected
const isDoubleTap = (timeSinceLastTap < 400); if (touchState.secondTapTimer) { clearTimeout(touchState.secondTapTimer); touchState.secondTapTimer = null; }
if (isDoubleTap && controlEnabled) { // Check distance from first tap
// Double-tap detected - wait to see if user holds (drag) or releases quickly (double-click) const dx = touch.clientX - oldStartX;
// Microsoft RD style: tap-tap-hold = drag, tap-tap-release = double-click const dy = touch.clientY - oldStartY;
touchState.dragHoldTimer = setTimeout(() => { const dist = Math.sqrt(dx * dx + dy * dy);
// Finger still down after 150ms = start drag
if (!touchState.moved) { if (dist > 50) {
touchState.isDragging = true; // Too far - treat as new first tap, complete previous click first
const x = Math.round(cursorState.x); clickAtCursor(0);
const y = Math.round(cursorState.y); console.log('[Touch] Second tap too far (' + Math.round(dist) + 'px), new first tap');
sendMouse('down', x, y, 0); // Move cursor to new position
const overlay = document.getElementById('cursor-overlay'); const rect = canvas.getBoundingClientRect();
overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) hue-rotate(90deg)'; cursorState.x = Math.max(0, Math.min(canvas.width - 1, cursorState.x + dx * canvas.width / rect.width * 1.5));
console.log('[Drag] Started at', x, y); 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.dragHoldTimer = null; touchState.state = T_FIRST_DOWN;
}, 150); // 150ms delay before starting drag // Set up long press for right click
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
touchState.longPressTimer = setTimeout(() => { touchState.longPressTimer = setTimeout(() => {
if (!touchState.moved) { if (!touchState.moved && touchState.state === T_FIRST_DOWN) {
clickAtCursor(2); // Right click clickAtCursor(2);
showTouchIndicator(touch.clientX, touch.clientY); 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 && touchState.state === T_FIRST_DOWN) {
clickAtCursor(2);
showTouchIndicator(touch.clientX, touch.clientY);
touchState.state = T_IDLE;
} }
touchState.longPressTimer = null; touchState.longPressTimer = null;
}, 500); }, 500);
@@ -1743,7 +1776,7 @@ inline std::string GetWebPageHTML() {
return; 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) // Pan when zoomed (only when control mode is OFF - view-only mode)
const touch = e.touches[0]; const touch = e.touches[0];
const dx = touch.clientX - zoomState.pinchCenterX; const dx = touch.clientX - zoomState.pinchCenterX;
@@ -1764,58 +1797,62 @@ inline std::string GetWebPageHTML() {
return; return;
} }
// Single finger move - touchpad mode (relative cursor movement) // Single finger move - state machine based
const touch = e.touches[0]; const touch = e.touches[0];
const dx = touch.clientX - touchState.lastX; const dx = touch.clientX - touchState.lastX;
const dy = touch.clientY - touchState.lastY; 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 totalDx = touch.clientX - touchState.startX;
const totalDy = touch.clientY - touchState.startY; const totalDy = touch.clientY - touchState.startY;
const totalDist = Math.sqrt(totalDx * totalDx + totalDy * totalDy); 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; touchState.moved = true;
// Cancel long press if moving
if (touchState.longPressTimer) { if (touchState.longPressTimer) {
clearTimeout(touchState.longPressTimer); clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null; touchState.longPressTimer = null;
} }
// If dragHoldTimer is pending (double-tap detected), start drag immediately on movement }
if (touchState.dragHoldTimer && !touchState.isDragging) {
clearTimeout(touchState.dragHoldTimer); const rect = canvas.getBoundingClientRect();
touchState.dragHoldTimer = null; const canvasDx = dx * canvas.width / rect.width;
// Start drag now const canvasDy = dy * canvas.height / rect.height;
touchState.isDragging = true;
// 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 x = Math.round(cursorState.x);
const y = Math.round(cursorState.y); const y = Math.round(cursorState.y);
sendMouse('down', x, y, 0); sendMouse('down', x, y, 0);
const overlay = document.getElementById('cursor-overlay'); const overlay = document.getElementById('cursor-overlay');
overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) hue-rotate(90deg)'; 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); console.log('[Touch] Drag started at', x, y);
}
} }
// Convert screen delta to canvas delta // Move cursor based on state
// getBoundingClientRect() always reflects current visual state (including CSS transform) if (touchState.state === T_MOVING || touchState.state === T_DRAGGING) {
// 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)
moveCursorBy(canvasDx, canvasDy); moveCursorBy(canvasDx, canvasDy);
} }
touchState.lastX = touch.clientX; touchState.lastX = touch.clientX;
touchState.lastY = touch.clientY; touchState.lastY = touch.clientY;
}, { passive: false }); }, { passive: false });
)HTML";
// Part 15b - Touch end handler
html += R"HTML(
canvas.addEventListener('touchend', function(e) { canvas.addEventListener('touchend', function(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent exiting fullscreen e.stopPropagation(); // Prevent exiting fullscreen
@@ -1849,45 +1886,53 @@ inline std::string GetWebPageHTML() {
return; return;
} }
// Reset cursor style // State machine based touchend
const overlay = document.getElementById('cursor-overlay'); const overlay = document.getElementById('cursor-overlay');
if (touchState.isDragging) {
// End drag at cursor position
const x = Math.round(cursorState.x); const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y); 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))'; if (touchState.longPressTimer) {
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) {
clearTimeout(touchState.longPressTimer); clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null; 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.moved = false;
touchState.touchCount = e.touches.length; touchState.touchCount = e.touches.length;
}, { passive: false }); }, { passive: false });