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)
|
// 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
|
touchState.longPressTimer = setTimeout(() => {
|
||||||
} else if (controlEnabled) {
|
if (!touchState.moved && touchState.state === T_FIRST_DOWN) {
|
||||||
// Single tap - set up for potential double-tap
|
clickAtCursor(2);
|
||||||
// Long press (500ms) for right click
|
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(() => {
|
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;
|
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);
|
|
||||||
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 rect = canvas.getBoundingClientRect();
|
||||||
const canvasDx = dx * canvas.width / rect.width;
|
const canvasDx = dx * canvas.width / rect.width;
|
||||||
const canvasDy = dy * canvas.height / rect.height;
|
const canvasDy = dy * canvas.height / rect.height;
|
||||||
|
|
||||||
// Move cursor (touchpad mode)
|
// State machine transitions and actions
|
||||||
if (touchState.isDragging) {
|
if (touchState.state === T_FIRST_DOWN && touchState.moved) {
|
||||||
// Dragging: move cursor and send move events
|
// First tap + move = pure cursor movement (no click involved)
|
||||||
moveCursorBy(canvasDx, canvasDy);
|
touchState.state = T_MOVING;
|
||||||
} else if (touchState.moved) {
|
console.log('[Touch] Moving cursor');
|
||||||
// Just moving cursor (not dragging yet)
|
}
|
||||||
|
|
||||||
|
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);
|
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');
|
||||||
|
const x = Math.round(cursorState.x);
|
||||||
|
const y = Math.round(cursorState.y);
|
||||||
|
|
||||||
if (touchState.isDragging) {
|
if (touchState.longPressTimer) {
|
||||||
// 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) {
|
|
||||||
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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user