1503 lines
53 KiB
JavaScript
1503 lines
53 KiB
JavaScript
(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);
|
||
})();
|
||
|