Files
crossdesk-web-client/control.js

1503 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const ControlType = {
mouse: 0,
keyboard: 1,
audio_capture: 2,
host_infomation: 3,
display_id: 4,
};
const MouseFlag = {
move: 0,
left_down: 1,
left_up: 2,
right_down: 3,
right_up: 4,
middle_down: 5,
middle_up: 6,
wheel_vertical: 7,
wheel_horizontal: 8,
};
const clamp01 = (value) => Math.max(0, Math.min(1, value));
const isTextInput = (el) => {
if (!el || !el.tagName) return false;
const tag = el.tagName.toLowerCase();
if (tag === "textarea") return true;
if (tag !== "input") return false;
const type = (el.getAttribute("type") || "text").toLowerCase();
return !["checkbox", "radio", "button", "submit", "reset"].includes(type);
};
class ControlManager {
constructor() {
this.dataChannel = null;
this.elements = {
video: document.getElementById("video"),
mediaContainer: document.getElementById("media"),
videoContainer: document.getElementById("video-container"),
virtualMouse: document.getElementById("virtual-mouse"),
virtualMouseHeader: document.getElementById("virtual-mouse-header"),
virtualLeftBtn: document.getElementById("virtual-left-btn"),
virtualRightBtn: document.getElementById("virtual-right-btn"),
virtualScrollUp: document.getElementById("virtual-scroll-up"),
virtualScrollDown: document.getElementById("virtual-scroll-down"),
virtualTouchpad: null,
virtualDragHandle: document.getElementById("virtual-mouse-drag-handle"),
mobileModeSelector: document.getElementById("mobile-mode-selector"),
mouseControlMode: document.getElementById("mouse-control-mode"),
virtualKeyboard: document.getElementById("virtual-keyboard"),
keyboardHeader: document.getElementById("keyboard-header"),
keyboardToggleMouse: document.getElementById("keyboard-toggle-mouse"),
keyboardToggle: document.getElementById("keyboard-toggle"),
keyboardClose: document.getElementById("keyboard-close"),
};
this.virtualKeyTimers = new Map(); // Store timers for each key element
this.virtualScrollTimers = new Map(); // Store timers for scroll buttons
this.state = {
pointerLocked: false,
normalizedPos: { x: 0.5, y: 0.5 },
lastPointerPos: null,
lastWheelAt: 0,
touchpadStart: null,
draggingVirtualMouse: false,
dragOffset: { x: 0, y: 0 },
draggingVirtualKeyboard: false,
keyboardDragOffset: { x: 0, y: 0 },
draggingPanel: false, // Track if status panel is being dragged
pointerLockToastTimer: null,
videoRect: null,
gestureActive: false,
gestureButton: null,
gestureStart: null,
isMobile: false,
mobileControlMode: "absolute", // "absolute" or "relative"
touchActive: false,
touchStartPos: null,
touchLastPos: null,
// Pinch zoom state
pinchZoomActive: false,
initialPinchDistance: 0,
initialScale: 1.0,
currentScale: 1.0,
lastDoubleTapTime: 0,
// Pan state (for dragging zoomed image)
initialPinchCenter: null,
initialTranslateX: 0,
initialTranslateY: 0,
currentTranslateX: 0,
currentTranslateY: 0,
};
this.onPointerLockChange = this.onPointerLockChange.bind(this);
this.onPointerLockError = this.onPointerLockError.bind(this);
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.onPointerCancel = this.onPointerCancel.bind(this);
this.onWheel = this.onWheel.bind(this);
this.onTouchStartFallback = this.onTouchStartFallback.bind(this);
this.onTouchMoveFallback = this.onTouchMoveFallback.bind(this);
this.onTouchEndFallback = this.onTouchEndFallback.bind(this);
this.onVirtualLeftStart = this.onVirtualLeftStart.bind(this);
this.onVirtualRightStart = this.onVirtualRightStart.bind(this);
this.onVirtualButtonMove = this.onVirtualButtonMove.bind(this);
this.onVirtualButtonEnd = this.onVirtualButtonEnd.bind(this);
this.onDragHandleTouchStart = this.onDragHandleTouchStart.bind(this);
this.onDragHandleTouchMove = this.onDragHandleTouchMove.bind(this);
this.onDragHandleTouchEnd = this.onDragHandleTouchEnd.bind(this);
this.onDragHandleClick = this.onDragHandleClick.bind(this);
this.onKeyboardDragHandleTouchStart = this.onKeyboardDragHandleTouchStart.bind(this);
this.onKeyboardDragHandleTouchMove = this.onKeyboardDragHandleTouchMove.bind(this);
this.onKeyboardDragHandleTouchEnd = this.onKeyboardDragHandleTouchEnd.bind(this);
this.onPinchStart = this.onPinchStart.bind(this);
this.onPinchMove = this.onPinchMove.bind(this);
this.onPinchEnd = this.onPinchEnd.bind(this);
this.init();
}
init() {
const { video } = this.elements;
if (!video) {
console.warn("CrossDeskControl: video element not found");
return;
}
video.style.pointerEvents = "auto";
video.tabIndex = 0;
this.bindPointerLockEvents();
this.bindPointerListeners();
this.bindKeyboardListeners();
this.setupVirtualMouse();
this.setupVirtualKeyboard();
}
setDataChannel(channel) {
this.dataChannel = channel;
}
isChannelOpen() {
return this.dataChannel && this.dataChannel.readyState === "open";
}
send(action) {
if (!this.isChannelOpen()) return false;
try {
const payload = JSON.stringify(action);
this.dataChannel.send(payload);
return true;
} catch (err) {
console.error("CrossDeskControl: failed to send action", err);
return false;
}
}
sendMouseAction({ x, y, flag, scroll = 0 }) {
// Don't send mouse events while dragging UI elements
if (this.isDraggingAnyElement()) {
return;
}
const numericFlag =
typeof flag === "string" ? MouseFlag[flag] ?? MouseFlag.move : flag | 0;
const action = {
type: ControlType.mouse,
mouse: {
x: clamp01(x),
y: clamp01(y),
s: scroll | 0,
flag: numericFlag,
},
};
this.send(action);
}
sendKeyboardAction(keyValue, isDown) {
const action = {
type: ControlType.keyboard,
keyboard: {
key_value: keyValue | 0,
flag: isDown ? 0 : 1,
},
};
this.send(action);
}
sendAudioCapture(enabled) {
const action = {
type: ControlType.audio_capture,
audio_capture: !!enabled,
};
this.send(action);
}
sendDisplayId(id) {
const action = {
type: ControlType.display_id,
display_id: id | 0,
};
this.send(action);
}
sendRawMessage(raw) {
if (!this.isChannelOpen()) return false;
try {
this.dataChannel.send(raw);
return true;
} catch (err) {
console.error("CrossDeskControl: failed to send raw message", err);
return false;
}
}
bindPointerLockEvents() {
document.addEventListener("pointerlockchange", this.onPointerLockChange);
document.addEventListener("pointerlockerror", this.onPointerLockError);
document.addEventListener("keydown", (event) => {
if (event.ctrlKey && event.key === "Escape") {
document.exitPointerLock?.();
}
});
}
onPointerLockChange() {
this.state.pointerLocked = document.pointerLockElement === this.elements.video;
if (this.state.pointerLocked) {
this.state.videoRect = this.elements.video?.getBoundingClientRect() ?? null;
} else {
this.state.videoRect = null;
this.showPointerLockToast(
"已退出鼠标锁定,按 Esc 或点击视频重新锁定(释放可按 Ctrl+Esc",
3000
);
}
}
onPointerLockError() {
this.showPointerLockToast("鼠标锁定失败", 2500);
}
bindPointerListeners() {
const { video } = this.elements;
if (!video) return;
try {
video.style.touchAction = "none";
} catch (err) {}
video.addEventListener("pointerdown", this.onPointerDown, {
passive: false,
});
document.addEventListener("pointermove", this.onPointerMove, {
passive: false,
});
document.addEventListener("pointerup", this.onPointerUp, {
passive: false,
});
document.addEventListener("pointercancel", this.onPointerCancel);
video.addEventListener("wheel", this.onWheel, { passive: false });
if (!window.PointerEvent) {
video.addEventListener("touchstart", this.onTouchStartFallback, {
passive: false,
});
document.addEventListener("touchmove", this.onTouchMoveFallback, {
passive: false,
});
document.addEventListener("touchend", this.onTouchEndFallback, {
passive: false,
});
document.addEventListener("touchcancel", this.onTouchEndFallback, {
passive: false,
});
}
// Pinch zoom will be set up in setupVirtualMouse() after isMobile is determined
}
onPointerDown(event) {
const button = typeof event.button === "number" ? event.button : 0;
if (button < 0) return;
// Skip if touching panel elements
const target = event.target;
if (target && (target.closest("#panel-collapsed-bar") || target.closest("#connected-panel"))) {
return;
}
// Skip if dragging panel
if (this.state.draggingPanel) {
return;
}
// 移动端模式下,触摸视频区域不触发点击事件,只移动鼠标位置
// Skip if pinch zoom is active
if (this.state.isMobile && event.pointerType === "touch" && !this.state.pinchZoomActive) {
event.preventDefault?.();
this.ensureVideoRect();
if (this.state.videoRect && this.isInsideVideo(event.clientX, event.clientY)) {
// 模式1指哪打哪 - 直接设置鼠标位置
if (this.state.mobileControlMode === "absolute") {
this.updateNormalizedFromClient(event.clientX, event.clientY);
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.move,
});
} else {
// 模式2增量模式 - 记录起始位置
this.state.touchActive = true;
this.state.touchStartPos = { x: event.clientX, y: event.clientY };
this.state.touchLastPos = { x: event.clientX, y: event.clientY };
}
}
this.elements.video?.setPointerCapture?.(event.pointerId ?? 0);
return;
}
event.preventDefault?.();
this.state.lastPointerPos = { x: event.clientX, y: event.clientY };
this.ensureVideoRect();
if (this.state.videoRect && this.isInsideVideo(event.clientX, event.clientY)) {
this.updateNormalizedFromClient(event.clientX, event.clientY);
this.requestPointerLock();
}
this.elements.video?.setPointerCapture?.(event.pointerId ?? 0);
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: this.buttonToFlag(button, true),
});
}
onPointerMove(event) {
// Skip if touching panel elements
const target = event.target;
if (target && (target.closest("#panel-collapsed-bar") || target.closest("#connected-panel"))) {
return;
}
// Skip if dragging panel
if (this.state.draggingPanel) {
return;
}
// Skip if pinch zoom is active
if (this.state.pinchZoomActive) {
return;
}
// 移动端增量模式处理
if (this.state.isMobile && event.pointerType === "touch" && this.state.touchActive && this.state.mobileControlMode === "relative") {
event.preventDefault?.();
this.ensureVideoRect();
if (!this.state.videoRect || !this.state.touchLastPos) return;
const deltaX = event.clientX - this.state.touchLastPos.x;
const deltaY = event.clientY - this.state.touchLastPos.y;
// 计算增量(相对于视频尺寸)
const deltaXNormalized = deltaX / this.state.videoRect.width;
const deltaYNormalized = deltaY / this.state.videoRect.height;
// 更新鼠标位置(增量模式)
this.state.normalizedPos.x = clamp01(this.state.normalizedPos.x + deltaXNormalized);
this.state.normalizedPos.y = clamp01(this.state.normalizedPos.y + deltaYNormalized);
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.move,
});
this.state.touchLastPos = { x: event.clientX, y: event.clientY };
return;
}
// 移动端指哪打哪模式处理
if (this.state.isMobile && event.pointerType === "touch" && this.state.mobileControlMode === "absolute") {
event.preventDefault?.();
this.ensureVideoRect();
if (!this.state.videoRect || !this.isInsideVideo(event.clientX, event.clientY)) return;
this.updateNormalizedFromClient(event.clientX, event.clientY);
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.move,
});
return;
}
// 桌面端处理
if (!this.state.pointerLocked && !this.state.lastPointerPos) return;
const movementX = this.state.pointerLocked
? event.movementX
: event.clientX - (this.state.lastPointerPos?.x ?? event.clientX);
const movementY = this.state.pointerLocked
? event.movementY
: event.clientY - (this.state.lastPointerPos?.y ?? event.clientY);
if (!this.state.pointerLocked) {
this.state.lastPointerPos = { x: event.clientX, y: event.clientY };
}
this.ensureVideoRect();
if (!this.state.videoRect) return;
if (this.state.pointerLocked) {
this.state.normalizedPos.x = clamp01(
this.state.normalizedPos.x + movementX / this.state.videoRect.width
);
this.state.normalizedPos.y = clamp01(
this.state.normalizedPos.y + movementY / this.state.videoRect.height
);
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.move,
});
return;
}
if (!this.isInsideVideo(event.clientX, event.clientY)) return;
const x = (event.clientX - this.state.videoRect.left) /
this.state.videoRect.width;
const y = (event.clientY - this.state.videoRect.top) /
this.state.videoRect.height;
this.state.normalizedPos = { x: clamp01(x), y: clamp01(y) };
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.move,
});
}
onPointerUp(event) {
// 移动端模式下,触摸结束不触发点击事件
if (this.state.isMobile && event.pointerType === "touch") {
this.elements.video?.releasePointerCapture?.(event.pointerId ?? 0);
this.state.touchActive = false;
this.state.touchStartPos = null;
this.state.touchLastPos = null;
return;
}
const button = typeof event.button === "number" ? event.button : 0;
this.elements.video?.releasePointerCapture?.(event.pointerId ?? 0);
this.state.lastPointerPos = null;
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: this.buttonToFlag(button, false),
});
}
onPointerCancel() {
this.state.lastPointerPos = null;
// 清理移动端触摸状态
if (this.state.isMobile) {
this.state.touchActive = false;
this.state.touchStartPos = null;
this.state.touchLastPos = null;
}
}
onWheel(event) {
const now = Date.now();
if (now - this.state.lastWheelAt < 50) return;
this.state.lastWheelAt = now;
this.ensureVideoRect();
if (!this.state.videoRect) return;
let coords = this.state.normalizedPos;
if (!this.state.pointerLocked) {
if (!this.isInsideVideo(event.clientX, event.clientY)) return;
coords = {
x: (event.clientX - this.state.videoRect.left) /
this.state.videoRect.width,
y: (event.clientY - this.state.videoRect.top) /
this.state.videoRect.height,
};
}
this.sendMouseAction({
x: coords.x,
y: coords.y,
flag: event.deltaY === 0 ? MouseFlag.wheel_horizontal : MouseFlag.wheel_vertical,
scroll: event.deltaY || event.deltaX,
});
event.preventDefault();
}
onTouchStartFallback(event) {
if (!event.touches?.length) return;
// Skip if touching panel elements
const target = event.target;
if (target && (target.closest("#panel-collapsed-bar") || target.closest("#connected-panel"))) {
return;
}
// Skip if pinch zoom is active, dragging panel, or if two touches (pinch gesture)
if (this.state.pinchZoomActive || this.state.draggingPanel || event.touches.length === 2) {
return;
}
const touch = event.touches[0];
event.preventDefault();
// 移动端模式下,触摸视频区域不触发点击事件
this.ensureVideoRect();
if (this.state.videoRect && this.isInsideVideo(touch.clientX, touch.clientY)) {
if (this.state.mobileControlMode === "absolute") {
// 模式1指哪打哪
this.updateNormalizedFromClient(touch.clientX, touch.clientY);
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.move,
});
} else {
// 模式2增量模式
this.state.touchActive = true;
this.state.touchStartPos = { x: touch.clientX, y: touch.clientY };
this.state.touchLastPos = { x: touch.clientX, y: touch.clientY };
}
}
}
onTouchMoveFallback(event) {
if (!event.touches?.length) return;
// Skip if touching panel elements
const target = event.target;
if (target && (target.closest("#panel-collapsed-bar") || target.closest("#connected-panel"))) {
return;
}
// Skip if pinch zoom is active, dragging panel, or if two touches (pinch gesture)
if (this.state.pinchZoomActive || this.state.draggingPanel || event.touches.length === 2) {
return;
}
const touch = event.touches[0];
event.preventDefault();
this.ensureVideoRect();
if (!this.state.videoRect) return;
if (this.state.mobileControlMode === "absolute") {
// 模式1指哪打哪
if (this.isInsideVideo(touch.clientX, touch.clientY)) {
this.updateNormalizedFromClient(touch.clientX, touch.clientY);
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.move,
});
}
} else if (this.state.touchActive && this.state.touchLastPos) {
// 模式2增量模式
const deltaX = touch.clientX - this.state.touchLastPos.x;
const deltaY = touch.clientY - this.state.touchLastPos.y;
const deltaXNormalized = deltaX / this.state.videoRect.width;
const deltaYNormalized = deltaY / this.state.videoRect.height;
this.state.normalizedPos.x = clamp01(this.state.normalizedPos.x + deltaXNormalized);
this.state.normalizedPos.y = clamp01(this.state.normalizedPos.y + deltaYNormalized);
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.move,
});
this.state.touchLastPos = { x: touch.clientX, y: touch.clientY };
}
}
onTouchEndFallback(event) {
// 移动端模式下,触摸结束不触发点击事件
this.state.touchActive = false;
this.state.touchStartPos = null;
this.state.touchLastPos = null;
}
buttonToFlag(button, isDown) {
const mapping = {
0: { down: MouseFlag.left_down, up: MouseFlag.left_up },
1: { down: MouseFlag.middle_down, up: MouseFlag.middle_up },
2: { down: MouseFlag.right_down, up: MouseFlag.right_up },
};
const record = mapping[button] || mapping[0];
return isDown ? record.down : record.up;
}
requestPointerLock() {
try {
this.elements.video?.requestPointerLock?.();
} catch (err) {
console.warn("CrossDeskControl: requestPointerLock failed", err);
}
}
ensureVideoRect() {
const { video } = this.elements;
if (!video) return;
this.state.videoRect = video.getBoundingClientRect();
}
isInsideVideo(clientX, clientY) {
const rect = this.state.videoRect;
if (!rect) return false;
return (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
);
}
updateNormalizedFromClient(clientX, clientY) {
if (!this.state.videoRect) return;
this.state.normalizedPos = {
x: clamp01((clientX - this.state.videoRect.left) / this.state.videoRect.width),
y: clamp01((clientY - this.state.videoRect.top) / this.state.videoRect.height),
};
}
bindKeyboardListeners() {
document.addEventListener("keydown", (event) => {
if (!this.isChannelOpen()) return;
if (event.repeat) return;
if (isTextInput(event.target)) return;
this.sendKeyboardAction(event.keyCode ?? 0, true);
});
document.addEventListener("keyup", (event) => {
if (!this.isChannelOpen()) return;
if (isTextInput(event.target)) return;
this.sendKeyboardAction(event.keyCode ?? 0, false);
});
}
setupVirtualMouse() {
const isDesktop = window.matchMedia(
"(hover: hover) and (pointer: fine)"
).matches;
this.state.isMobile = !isDesktop;
if (isDesktop) {
if (this.elements.virtualMouse) {
this.elements.virtualMouse.style.pointerEvents = "none";
}
if (this.elements.mobileModeSelector) {
this.elements.mobileModeSelector.style.display = "none";
}
return;
}
// 显示移动端模式选择器
if (this.elements.mobileModeSelector) {
this.elements.mobileModeSelector.style.display = "flex";
}
// 绑定模式切换事件
if (this.elements.mouseControlMode) {
this.elements.mouseControlMode.addEventListener("change", (event) => {
this.state.mobileControlMode = event.target.value;
});
this.state.mobileControlMode = this.elements.mouseControlMode.value;
}
this.elements.virtualLeftBtn?.addEventListener("touchstart", this.onVirtualLeftStart, { passive: false });
this.elements.virtualRightBtn?.addEventListener("touchstart", this.onVirtualRightStart, { passive: false });
document.addEventListener("touchmove", this.onVirtualButtonMove, { passive: false });
document.addEventListener("touchend", this.onVirtualButtonEnd, { passive: false });
document.addEventListener("touchcancel", this.onVirtualButtonEnd, { passive: false });
// Scroll up button with long-press support
if (this.elements.virtualScrollUp) {
const handleScrollUpDown = (e) => {
e.preventDefault();
this.handleVirtualScrollPress(this.elements.virtualScrollUp, "up", true);
};
const handleScrollUpUp = (e) => {
e.preventDefault();
this.handleVirtualScrollPress(this.elements.virtualScrollUp, "up", false);
};
this.elements.virtualScrollUp.addEventListener("mousedown", handleScrollUpDown, { passive: false });
this.elements.virtualScrollUp.addEventListener("mouseup", handleScrollUpUp, { passive: false });
this.elements.virtualScrollUp.addEventListener("mouseleave", handleScrollUpUp, { passive: false });
this.elements.virtualScrollUp.addEventListener("touchstart", handleScrollUpDown, { passive: false });
this.elements.virtualScrollUp.addEventListener("touchend", handleScrollUpUp, { passive: false });
this.elements.virtualScrollUp.addEventListener("touchcancel", handleScrollUpUp, { passive: false });
}
// Scroll down button with long-press support
if (this.elements.virtualScrollDown) {
const handleScrollDownDown = (e) => {
e.preventDefault();
this.handleVirtualScrollPress(this.elements.virtualScrollDown, "down", true);
};
const handleScrollDownUp = (e) => {
e.preventDefault();
this.handleVirtualScrollPress(this.elements.virtualScrollDown, "down", false);
};
this.elements.virtualScrollDown.addEventListener("mousedown", handleScrollDownDown, { passive: false });
this.elements.virtualScrollDown.addEventListener("mouseup", handleScrollDownUp, { passive: false });
this.elements.virtualScrollDown.addEventListener("mouseleave", handleScrollDownUp, { passive: false });
this.elements.virtualScrollDown.addEventListener("touchstart", handleScrollDownDown, { passive: false });
this.elements.virtualScrollDown.addEventListener("touchend", handleScrollDownUp, { passive: false });
this.elements.virtualScrollDown.addEventListener("touchcancel", handleScrollDownUp, { passive: false });
}
this.bindVirtualMouseDragging();
this.bindVirtualKeyboardDragging();
// Add pinch zoom support for mobile devices (after isMobile is set)
if (this.state.isMobile && this.elements.video) {
const video = this.elements.video;
video.addEventListener("touchstart", this.onPinchStart, {
passive: false,
});
document.addEventListener("touchmove", this.onPinchMove, {
passive: false,
});
document.addEventListener("touchend", this.onPinchEnd, {
passive: false,
});
document.addEventListener("touchcancel", this.onPinchEnd, {
passive: false,
});
}
}
setupVirtualKeyboard() {
const isDesktop = window.matchMedia(
"(hover: hover) and (pointer: fine)"
).matches;
// Only show virtual keyboard on mobile devices
if (isDesktop) {
if (this.elements.virtualKeyboard) {
this.elements.virtualKeyboard.style.display = "none";
}
// Keep keyboard toggle button visible in panel even on desktop
// Don't hide it, and don't return early so button setup continues
}
// Show keyboard toggle button on virtual mouse (always visible in panel)
if (this.elements.keyboardToggleMouse) {
this.elements.keyboardToggleMouse.style.display = "block";
this.elements.keyboardToggleMouse.addEventListener("click", () => {
this.toggleVirtualKeyboard();
});
}
// Keyboard header buttons
if (this.elements.keyboardToggle) {
this.elements.keyboardToggle.addEventListener("click", () => {
this.toggleVirtualKeyboard();
});
}
if (this.elements.keyboardClose) {
this.elements.keyboardClose.addEventListener("click", () => {
this.hideVirtualKeyboard();
});
}
// Bind keyboard key events
const keyboardKeys = document.querySelectorAll(".keyboard-key");
keyboardKeys.forEach((key) => {
const handleKeyDown = (e) => {
e.preventDefault();
this.handleVirtualKeyPress(key, true);
};
const handleKeyUp = (e) => {
e.preventDefault();
this.handleVirtualKeyPress(key, false);
// Remove focus after a short delay to ensure it happens after all event handlers
setTimeout(() => {
if (document.activeElement === key) {
key.blur();
}
// Force remove any inline styles that might persist
key.style.backgroundColor = "";
key.style.transform = "";
key.style.boxShadow = "";
}, 0);
};
key.addEventListener("mousedown", handleKeyDown);
key.addEventListener("mouseup", handleKeyUp);
key.addEventListener("mouseleave", handleKeyUp);
key.addEventListener("touchstart", handleKeyDown, { passive: false });
key.addEventListener("touchend", handleKeyUp, { passive: false });
key.addEventListener("touchcancel", handleKeyUp, { passive: false });
// Store key element reference for cleanup
key._keyboardKeyRef = key;
});
}
toggleVirtualKeyboard() {
if (!this.elements.virtualKeyboard) return;
const isVisible = this.elements.virtualKeyboard.style.display !== "none";
if (isVisible) {
this.hideVirtualKeyboard();
} else {
this.showVirtualKeyboard();
}
}
showVirtualKeyboard() {
if (!this.elements.virtualKeyboard) return;
this.elements.virtualKeyboard.style.display = "block";
}
hideVirtualKeyboard() {
if (!this.elements.virtualKeyboard) return;
this.elements.virtualKeyboard.style.display = "none";
}
handleVirtualKeyPress(keyElement, isDown) {
if (!this.isChannelOpen()) return;
const keyCode = parseInt(keyElement.getAttribute("data-keycode"), 10);
if (isNaN(keyCode)) return;
if (isDown) {
// Clear any existing timer for this key
this.stopVirtualKeyRepeat(keyElement);
// Send initial keydown
this.sendKeyboardAction(keyCode, true);
// Visual feedback - pressed state
keyElement.style.backgroundColor = "rgba(180, 180, 180, 0.95)";
keyElement.style.transform = "scale(0.92)";
keyElement.style.boxShadow = "0 1px 2px rgba(0, 0, 0, 0.3)";
// Store timer info for this key
const timerInfo = {
longPressTimer: null,
repeatTimer: null
};
// Set up long press detection
timerInfo.longPressTimer = setTimeout(() => {
// After 300ms, start repeating
timerInfo.repeatTimer = setInterval(() => {
if (this.isChannelOpen()) {
this.sendKeyboardAction(keyCode, true);
// Send keyup immediately after keydown for repeat
setTimeout(() => {
this.sendKeyboardAction(keyCode, false);
}, 30);
}
}, 100); // Repeat every 100ms
}, 300); // Long press threshold: 300ms
this.virtualKeyTimers.set(keyElement, timerInfo);
} else {
// Stop repeating
this.stopVirtualKeyRepeat(keyElement);
// Send keyup
this.sendKeyboardAction(keyCode, false);
// Visual feedback - released state - clear inline styles to restore CSS
// Use setTimeout to ensure this happens after browser default styles are applied
setTimeout(() => {
// Remove focus to prevent browser default focus styles
if (document.activeElement === keyElement) {
keyElement.blur();
}
// Force clear all inline styles
keyElement.style.backgroundColor = "";
keyElement.style.transform = "";
keyElement.style.boxShadow = "";
keyElement.style.outline = "";
}, 0);
}
}
stopVirtualKeyRepeat(keyElement) {
const timerInfo = this.virtualKeyTimers.get(keyElement);
if (timerInfo) {
if (timerInfo.longPressTimer) {
clearTimeout(timerInfo.longPressTimer);
}
if (timerInfo.repeatTimer) {
clearInterval(timerInfo.repeatTimer);
}
this.virtualKeyTimers.delete(keyElement);
}
}
emitVirtualWheel(direction = "up") {
// direction: "up" or "down"
// Up scroll: negative value (scroll up)
// Down scroll: positive value (scroll down)
const scrollValue = direction === "up" ? -1 : 1;
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.wheel_vertical,
scroll: scrollValue,
});
}
handleVirtualScrollPress(buttonElement, direction, isDown) {
if (!this.isChannelOpen()) return;
if (isDown) {
// Clear any existing timer for this button
this.stopVirtualScrollRepeat(buttonElement);
// Send initial scroll
this.emitVirtualWheel(direction);
// Visual feedback - pressed state
buttonElement.style.backgroundColor = "rgba(180, 180, 180, 0.95)";
buttonElement.style.transform = "scale(0.92)";
buttonElement.style.boxShadow = "0 1px 2px rgba(0, 0, 0, 0.3)";
// Store timer info for this button
const timerInfo = {
longPressTimer: null,
repeatTimer: null
};
// Set up long press detection
timerInfo.longPressTimer = setTimeout(() => {
// After 300ms, start repeating
timerInfo.repeatTimer = setInterval(() => {
if (this.isChannelOpen()) {
this.emitVirtualWheel(direction);
}
}, 100); // Repeat every 100ms
}, 300); // Long press threshold: 300ms
this.virtualScrollTimers.set(buttonElement, timerInfo);
} else {
// Stop repeating
this.stopVirtualScrollRepeat(buttonElement);
// Visual feedback - released state - clear inline styles to restore CSS
setTimeout(() => {
// Remove focus to prevent browser default focus styles
if (document.activeElement === buttonElement) {
buttonElement.blur();
}
// Force clear all inline styles
buttonElement.style.backgroundColor = "";
buttonElement.style.transform = "";
buttonElement.style.boxShadow = "";
buttonElement.style.outline = "";
}, 0);
}
}
stopVirtualScrollRepeat(buttonElement) {
const timerInfo = this.virtualScrollTimers.get(buttonElement);
if (timerInfo) {
if (timerInfo.longPressTimer) {
clearTimeout(timerInfo.longPressTimer);
}
if (timerInfo.repeatTimer) {
clearInterval(timerInfo.repeatTimer);
}
this.virtualScrollTimers.delete(buttonElement);
}
}
onVirtualLeftStart(event) {
const touch = event.touches?.[0];
if (!touch) return;
event.preventDefault();
this.ensureVideoRect();
this.state.gestureActive = true;
this.state.gestureButton = { down: MouseFlag.left_down, up: MouseFlag.left_up };
this.state.gestureStart = {
x: touch.clientX,
y: touch.clientY,
normalizedX: this.state.normalizedPos.x,
normalizedY: this.state.normalizedPos.y,
};
// 按下时设置为蓝色
if (this.elements.virtualLeftBtn) {
this.elements.virtualLeftBtn.style.backgroundColor = "var(--primary-color)";
this.elements.virtualLeftBtn.style.color = "#fff";
}
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.left_down,
});
}
onVirtualRightStart(event) {
const touch = event.touches?.[0];
if (!touch) return;
event.preventDefault();
this.ensureVideoRect();
this.state.gestureActive = true;
this.state.gestureButton = { down: MouseFlag.right_down, up: MouseFlag.right_up };
this.state.gestureStart = {
x: touch.clientX,
y: touch.clientY,
normalizedX: this.state.normalizedPos.x,
normalizedY: this.state.normalizedPos.y,
};
// 按下时设置为蓝色
if (this.elements.virtualRightBtn) {
this.elements.virtualRightBtn.style.backgroundColor = "var(--primary-color)";
this.elements.virtualRightBtn.style.color = "#fff";
}
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.right_down,
});
}
onVirtualButtonMove(event) {
if (!this.state.gestureActive || !this.state.gestureStart) return;
const touch = event.touches?.[0];
if (!touch) return;
event.preventDefault();
this.ensureVideoRect();
if (!this.state.videoRect) return;
const sensitivity = 2;
const deltaX = touch.clientX - this.state.gestureStart.x;
const deltaY = touch.clientY - this.state.gestureStart.y;
const newX = this.state.gestureStart.normalizedX + (deltaX / this.state.videoRect.width) * sensitivity;
const newY = this.state.gestureStart.normalizedY + (deltaY / this.state.videoRect.height) * sensitivity;
this.state.normalizedPos = { x: clamp01(newX), y: clamp01(newY) };
this.sendMouseAction({ x: this.state.normalizedPos.x, y: this.state.normalizedPos.y, flag: MouseFlag.move });
}
onVirtualButtonEnd(event) {
if (!this.state.gestureActive) return;
event.preventDefault?.();
const upFlag = this.state.gestureButton?.up ?? MouseFlag.left_up;
this.sendMouseAction({ x: this.state.normalizedPos.x, y: this.state.normalizedPos.y, flag: upFlag });
// 释放时恢复为原始颜色 - 清除内联样式让CSS生效
if (upFlag === MouseFlag.left_up && this.elements.virtualLeftBtn) {
this.elements.virtualLeftBtn.style.backgroundColor = "";
this.elements.virtualLeftBtn.style.color = "";
if (document.activeElement === this.elements.virtualLeftBtn) {
this.elements.virtualLeftBtn.blur();
}
} else if (upFlag === MouseFlag.right_up && this.elements.virtualRightBtn) {
this.elements.virtualRightBtn.style.backgroundColor = "";
this.elements.virtualRightBtn.style.color = "";
if (document.activeElement === this.elements.virtualRightBtn) {
this.elements.virtualRightBtn.blur();
}
}
this.state.gestureActive = false;
this.state.gestureButton = null;
this.state.gestureStart = null;
}
bindVirtualMouseDragging() {
const { virtualMouse, virtualMouseHeader, videoContainer } = this.elements;
if (!virtualMouse || !virtualMouseHeader || !videoContainer) return;
virtualMouseHeader.addEventListener("touchstart", this.onDragHandleTouchStart, {
passive: false,
});
document.addEventListener("touchmove", this.onDragHandleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this.onDragHandleTouchEnd, {
passive: false,
});
document.addEventListener("touchcancel", this.onDragHandleTouchEnd, {
passive: false,
});
// 确保键盘切换按钮点击时不会触发拖动
const keyboardToggleMouse = document.getElementById("keyboard-toggle-mouse");
if (keyboardToggleMouse) {
keyboardToggleMouse.addEventListener("touchstart", (e) => {
e.stopPropagation();
});
keyboardToggleMouse.addEventListener("click", (e) => {
e.stopPropagation();
});
}
}
bindVirtualKeyboardDragging() {
const { virtualKeyboard, keyboardHeader, keyboardClose, videoContainer } = this.elements;
if (!virtualKeyboard || !keyboardHeader || !videoContainer) return;
keyboardHeader.addEventListener("touchstart", this.onKeyboardDragHandleTouchStart, {
passive: false,
});
document.addEventListener("touchmove", this.onKeyboardDragHandleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this.onKeyboardDragHandleTouchEnd, {
passive: false,
});
document.addEventListener("touchcancel", this.onKeyboardDragHandleTouchEnd, {
passive: false,
});
// 确保关闭按钮点击时不会触发拖动
if (keyboardClose) {
keyboardClose.addEventListener("touchstart", (e) => {
e.stopPropagation();
});
keyboardClose.addEventListener("click", (e) => {
e.stopPropagation();
});
}
}
onDragHandleTouchStart(event) {
const touch = event.touches?.[0];
if (!touch || !this.elements.virtualMouse) return;
// 检查是否点击在按钮上
const target = event.target;
if (target && (target.id === "keyboard-toggle-mouse" || target.closest("#keyboard-toggle-mouse"))) {
return; // 不触发拖动
}
event.preventDefault();
const rect = this.elements.virtualMouse.getBoundingClientRect();
this.state.draggingVirtualMouse = true;
// 添加dragging类以禁用transition
this.elements.virtualMouse.classList.add("dragging");
this.state.dragOffset = {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top,
};
}
onDragHandleTouchMove(event) {
if (!this.state.draggingVirtualMouse) return;
const touch = event.touches?.[0];
if (!touch || !this.elements.videoContainer || !this.elements.virtualMouse)
return;
event.preventDefault();
const containerRect = this.elements.videoContainer.getBoundingClientRect();
// 直接使用触摸位置,减去偏移量
let newX = touch.clientX - this.state.dragOffset.x - containerRect.left;
let newY = touch.clientY - this.state.dragOffset.y - containerRect.top;
const maxX = Math.max(
0,
containerRect.width - this.elements.virtualMouse.offsetWidth
);
const maxY = Math.max(
0,
containerRect.height - this.elements.virtualMouse.offsetHeight
);
newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(0, Math.min(newY, maxY));
// 直接更新位置不使用requestAnimationFrame以保持跟手
this.elements.virtualMouse.style.left = `${newX}px`;
this.elements.virtualMouse.style.top = `${newY}px`;
this.elements.virtualMouse.style.bottom = "auto";
this.elements.virtualMouse.style.transform = "none";
}
onDragHandleTouchEnd() {
this.state.draggingVirtualMouse = false;
// 移除dragging类以恢复transition
if (this.elements.virtualMouse) {
this.elements.virtualMouse.classList.remove("dragging");
}
}
onDragHandleClick(event) {
event.stopPropagation();
this.elements.virtualMouse?.classList.toggle("minimized");
}
onKeyboardDragHandleTouchStart(event) {
const touch = event.touches?.[0];
if (!touch || !this.elements.virtualKeyboard) return;
// 检查是否点击在关闭按钮上
const target = event.target;
if (target && (target.id === "keyboard-close" || target.closest("#keyboard-close"))) {
return; // 不触发拖动
}
event.preventDefault();
const rect = this.elements.virtualKeyboard.getBoundingClientRect();
this.state.draggingVirtualKeyboard = true;
// 添加dragging类以禁用transition
this.elements.virtualKeyboard.classList.add("dragging");
this.state.keyboardDragOffset = {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top,
};
}
onKeyboardDragHandleTouchMove(event) {
if (!this.state.draggingVirtualKeyboard) return;
const touch = event.touches?.[0];
if (!touch || !this.elements.videoContainer || !this.elements.virtualKeyboard)
return;
event.preventDefault();
const containerRect = this.elements.videoContainer.getBoundingClientRect();
// 直接使用触摸位置,减去偏移量
let newX = touch.clientX - this.state.keyboardDragOffset.x - containerRect.left;
let newY = touch.clientY - this.state.keyboardDragOffset.y - containerRect.top;
const maxX = Math.max(
0,
containerRect.width - this.elements.virtualKeyboard.offsetWidth
);
const maxY = Math.max(
0,
containerRect.height - this.elements.virtualKeyboard.offsetHeight
);
newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(0, Math.min(newY, maxY));
// 直接更新位置不使用requestAnimationFrame以保持跟手
this.elements.virtualKeyboard.style.left = `${newX}px`;
this.elements.virtualKeyboard.style.top = `${newY}px`;
this.elements.virtualKeyboard.style.bottom = "auto";
this.elements.virtualKeyboard.style.transform = "none";
}
onKeyboardDragHandleTouchEnd() {
this.state.draggingVirtualKeyboard = false;
// 移除dragging类以恢复transition
if (this.elements.virtualKeyboard) {
this.elements.virtualKeyboard.classList.remove("dragging");
}
}
showPointerLockToast(text, duration = 2500) {
let toast = document.getElementById("pointerlock-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "pointerlock-toast";
Object.assign(toast.style, {
position: "fixed",
left: "50%",
bottom: "24px",
transform: "translateX(-50%)",
background: "rgba(0,0,0,0.75)",
color: "#fff",
padding: "8px 12px",
borderRadius: "6px",
fontSize: "13px",
zIndex: "9999",
pointerEvents: "none",
opacity: "1",
transition: "opacity 0.2s",
});
document.body.appendChild(toast);
}
toast.textContent = text;
toast.style.opacity = "1";
if (this.state.pointerLockToastTimer) {
clearTimeout(this.state.pointerLockToastTimer);
}
this.state.pointerLockToastTimer = setTimeout(() => {
toast.style.opacity = "0";
this.state.pointerLockToastTimer = null;
}, duration);
}
handleExternalMouseEvent(event) {
if (!event || !event.type) return;
// Don't handle mouse events while dragging UI elements
if (this.isDraggingAnyElement()) {
return;
}
switch (event.type) {
case "mousedown":
this.onPointerDown(event);
break;
case "mouseup":
this.onPointerUp(event);
break;
case "mousemove":
this.onPointerMove(event);
break;
case "wheel":
this.onWheel(event);
break;
default:
break;
}
}
isDraggingAnyElement() {
return (
this.state.draggingVirtualMouse ||
this.state.draggingVirtualKeyboard ||
this.state.draggingPanel
);
}
setDraggingPanel(isDragging) {
this.state.draggingPanel = isDragging;
}
// Calculate distance between two touch points
getTouchDistance(touch1, touch2) {
const dx = touch2.clientX - touch1.clientX;
const dy = touch2.clientY - touch1.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
// Get center point between two touches
getTouchCenter(touch1, touch2) {
return {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2,
};
}
// Pinch zoom handlers
onPinchStart(event) {
// Only handle on video element
if (event.target !== this.elements.video && !this.elements.video?.contains(event.target)) {
return;
}
if (event.touches.length === 2) {
event.preventDefault();
event.stopPropagation();
this.state.pinchZoomActive = true;
const touch1 = event.touches[0];
const touch2 = event.touches[1];
this.state.initialPinchDistance = this.getTouchDistance(touch1, touch2);
this.state.initialScale = this.state.currentScale;
// Record initial center point and translate for panning
this.state.initialPinchCenter = this.getTouchCenter(touch1, touch2);
this.state.initialTranslateX = this.state.currentTranslateX;
this.state.initialTranslateY = this.state.currentTranslateY;
// Clear single touch state to prevent mouse events
this.state.touchActive = false;
this.state.touchStartPos = null;
this.state.touchLastPos = null;
} else if (event.touches.length === 1 && !this.state.pinchZoomActive) {
// Single touch - check for double tap to reset zoom
const now = Date.now();
if (now - this.state.lastDoubleTapTime < 300) {
// Double tap detected - reset zoom
event.preventDefault();
this.resetZoom();
this.state.lastDoubleTapTime = 0;
} else {
this.state.lastDoubleTapTime = now;
}
}
}
onPinchMove(event) {
// Check for two touches first, even if pinchZoomActive is false (might have started with one touch)
if (event.touches.length === 2) {
if (!this.state.pinchZoomActive) {
// Start pinch zoom if not already active
this.state.pinchZoomActive = true;
const touch1 = event.touches[0];
const touch2 = event.touches[1];
this.state.initialPinchDistance = this.getTouchDistance(touch1, touch2);
this.state.initialScale = this.state.currentScale;
this.state.initialPinchCenter = this.getTouchCenter(touch1, touch2);
this.state.initialTranslateX = this.state.currentTranslateX;
this.state.initialTranslateY = this.state.currentTranslateY;
}
event.preventDefault();
event.stopPropagation();
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const currentDistance = this.getTouchDistance(touch1, touch2);
const currentCenter = this.getTouchCenter(touch1, touch2);
// Calculate scale factor
const scaleFactor = currentDistance / this.state.initialPinchDistance;
const newScale = this.state.initialScale * scaleFactor;
// Limit scale between 1.0x (initial size) and 3x
const clampedScale = Math.max(1.0, Math.min(3.0, newScale));
this.state.currentScale = clampedScale;
// Calculate pan (translation) based on center point movement
if (this.state.initialPinchCenter) {
const deltaX = currentCenter.x - this.state.initialPinchCenter.x;
const deltaY = currentCenter.y - this.state.initialPinchCenter.y;
// Calculate new translate values based on initial translate + delta
let newTranslateX = this.state.initialTranslateX + deltaX;
let newTranslateY = this.state.initialTranslateY + deltaY;
// Constrain translation to keep image within bounds
if (this.elements.video && this.elements.videoContainer) {
this.ensureVideoRect();
if (this.state.videoRect) {
const videoWidth = this.state.videoRect.width;
const videoHeight = this.state.videoRect.height;
// Calculate maximum allowed translation
// When scaled, the image is larger, so we can move it more
const scaledWidth = videoWidth * clampedScale;
const scaledHeight = videoHeight * clampedScale;
const maxTranslateX = Math.max(0, (scaledWidth - videoWidth) / 2);
const maxTranslateY = Math.max(0, (scaledHeight - videoHeight) / 2);
// Clamp translation values
newTranslateX = Math.max(-maxTranslateX, Math.min(maxTranslateX, newTranslateX));
newTranslateY = Math.max(-maxTranslateY, Math.min(maxTranslateY, newTranslateY));
}
}
this.state.currentTranslateX = newTranslateX;
this.state.currentTranslateY = newTranslateY;
}
// Apply combined transform (scale + translate) to video element
if (this.elements.video) {
this.elements.video.style.transform = `scale(${clampedScale}) translate(${this.state.currentTranslateX}px, ${this.state.currentTranslateY}px)`;
this.elements.video.style.transformOrigin = "center center";
}
} else if (this.state.pinchZoomActive && event.touches.length < 2) {
// Pinch ended
this.state.pinchZoomActive = false;
this.state.initialPinchDistance = 0;
this.state.initialPinchCenter = null;
}
}
onPinchEnd(event) {
if (this.state.pinchZoomActive && event.touches.length < 2) {
this.state.pinchZoomActive = false;
this.state.initialPinchDistance = 0;
this.state.initialPinchCenter = null;
// Don't prevent default for single touch after pinch ends
// This allows normal touch handling to resume
}
}
resetZoom() {
this.state.currentScale = 1.0;
this.state.initialScale = 1.0;
this.state.currentTranslateX = 0;
this.state.currentTranslateY = 0;
this.state.initialTranslateX = 0;
this.state.initialTranslateY = 0;
if (this.elements.video) {
this.elements.video.style.transform = "scale(1) translate(0, 0)";
this.elements.video.style.transformOrigin = "center center";
}
}
}
const control = new ControlManager();
window.CrossDeskControl = control;
window.sendRemoteActionAt = (x, y, flag, scroll) =>
control.sendMouseAction({ x, y, flag, scroll });
window.sendMouseEvent = (event) => control.handleExternalMouseEvent(event);
})();