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)
// 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) {
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);
} 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;
}, 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 rect = canvas.getBoundingClientRect();
const canvasDx = dx * canvas.width / rect.width;
const canvasDy = dy * canvas.height / rect.height;
// 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('[Drag] Started early on movement at', x, y);
}
console.log('[Touch] Drag started 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)
// 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');
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 });