Fix: Web remote desktop double-tap drag functionality
This commit is contained in:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user