Files
crossdesk-web-client/control.js

806 lines
24 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"),
dataLog: document.getElementById("data-channel"),
mediaContainer: document.getElementById("media"),
videoContainer: document.getElementById("video-container"),
fullscreenBtn: document.getElementById("fullscreen-btn"),
realFullscreenBtn: document.getElementById("real-fullscreen-btn"),
virtualMouse: document.getElementById("virtual-mouse"),
virtualLeftBtn: document.getElementById("virtual-left-btn"),
virtualRightBtn: document.getElementById("virtual-right-btn"),
virtualWheel: document.getElementById("virtual-wheel"),
virtualTouchpad: document.getElementById("virtual-touchpad"),
virtualDragHandle: document.getElementById("virtual-mouse-drag-handle"),
};
this.state = {
pointerLocked: false,
normalizedPos: { x: 0.5, y: 0.5 },
lastPointerPos: null,
lastWheelAt: 0,
isFullscreen: false,
isRealFullscreen: false,
touchpadStart: null,
draggingVirtualMouse: false,
dragOffset: { x: 0, y: 0 },
pointerLockToastTimer: null,
videoRect: null,
};
this.virtualWheelTimer = null;
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.onVirtualWheelStart = this.onVirtualWheelStart.bind(this);
this.onVirtualWheelEnd = this.onVirtualWheelEnd.bind(this);
this.onTouchpadStart = this.onTouchpadStart.bind(this);
this.onTouchpadMove = this.onTouchpadMove.bind(this);
this.onTouchpadEnd = this.onTouchpadEnd.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.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.setupFullscreenButtons();
}
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);
this.logDataChannel(payload);
return true;
} catch (err) {
console.error("CrossDeskControl: failed to send action", err);
return false;
}
}
sendMouseAction({ x, y, flag, scroll = 0 }) {
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);
this.logDataChannel(raw);
return true;
} catch (err) {
console.error("CrossDeskControl: failed to send raw message", err);
return false;
}
}
logDataChannel(text) {
const { dataLog } = this.elements;
if (!dataLog) return;
dataLog.textContent += `> ${text}\n`;
dataLog.scrollTop = dataLog.scrollHeight;
}
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
);
}
if (this.elements.dataLog) {
this.elements.dataLog.textContent += `[pointerlock ${
this.state.pointerLocked ? "entered" : "exited"
}]\n`;
this.elements.dataLog.scrollTop = this.elements.dataLog.scrollHeight;
}
}
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,
});
}
}
onPointerDown(event) {
const button = typeof event.button === "number" ? event.button : 0;
if (button < 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) {
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) {
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;
}
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;
const touch = event.touches[0];
this.state.lastPointerPos = { x: touch.clientX, y: touch.clientY };
event.preventDefault();
this.onPointerDown({
button: 0,
clientX: touch.clientX,
clientY: touch.clientY,
});
}
onTouchMoveFallback(event) {
if (!this.state.lastPointerPos || !event.touches?.length) return;
const touch = event.touches[0];
event.preventDefault();
this.onPointerMove({
clientX: touch.clientX,
clientY: touch.clientY,
movementX: touch.clientX - this.state.lastPointerPos.x,
movementY: touch.clientY - this.state.lastPointerPos.y,
});
this.state.lastPointerPos = { x: touch.clientX, y: touch.clientY };
}
onTouchEndFallback(event) {
const touch = event.changedTouches?.[0];
this.onPointerUp({
button: 0,
clientX: touch?.clientX ?? 0,
clientY: touch?.clientY ?? 0,
});
this.state.lastPointerPos = 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;
if (isDesktop) {
if (this.elements.virtualMouse) {
this.elements.virtualMouse.style.pointerEvents = "none";
}
return;
}
this.elements.virtualLeftBtn?.addEventListener(
"touchstart",
(event) => {
event.preventDefault();
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.left_down,
});
},
{ passive: false }
);
this.elements.virtualLeftBtn?.addEventListener(
"touchend",
(event) => {
event.preventDefault();
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.left_up,
});
},
{ passive: false }
);
this.elements.virtualRightBtn?.addEventListener(
"touchstart",
(event) => {
event.preventDefault();
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.right_down,
});
},
{ passive: false }
);
this.elements.virtualRightBtn?.addEventListener(
"touchend",
(event) => {
event.preventDefault();
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.right_up,
});
},
{ passive: false }
);
this.elements.virtualWheel?.addEventListener(
"touchstart",
this.onVirtualWheelStart,
{ passive: false }
);
this.elements.virtualWheel?.addEventListener(
"touchend",
this.onVirtualWheelEnd,
{ passive: false }
);
this.elements.virtualWheel?.addEventListener(
"touchcancel",
this.onVirtualWheelEnd,
{ passive: false }
);
this.elements.virtualTouchpad?.addEventListener(
"touchstart",
this.onTouchpadStart,
{ passive: false }
);
this.elements.virtualTouchpad?.addEventListener(
"touchmove",
this.onTouchpadMove,
{ passive: false }
);
this.elements.virtualTouchpad?.addEventListener(
"touchend",
this.onTouchpadEnd,
{ passive: false }
);
this.elements.virtualTouchpad?.addEventListener(
"touchcancel",
this.onTouchpadEnd,
{ passive: false }
);
this.bindVirtualMouseDragging();
}
onVirtualWheelStart(event) {
event.preventDefault();
this.emitVirtualWheel();
this.virtualWheelTimer = setInterval(() => this.emitVirtualWheel(), 100);
}
onVirtualWheelEnd(event) {
event.preventDefault();
if (this.virtualWheelTimer) {
clearInterval(this.virtualWheelTimer);
this.virtualWheelTimer = null;
}
}
emitVirtualWheel() {
this.sendMouseAction({
x: this.state.normalizedPos.x,
y: this.state.normalizedPos.y,
flag: MouseFlag.wheel_vertical,
scroll: -20,
});
}
onTouchpadStart(event) {
const touch = event.touches?.[0];
if (!touch) return;
event.preventDefault();
this.state.touchpadStart = {
x: touch.clientX,
y: touch.clientY,
normalizedX: this.state.normalizedPos.x,
normalizedY: this.state.normalizedPos.y,
};
}
onTouchpadMove(event) {
const touch = event.touches?.[0];
if (!touch || !this.state.touchpadStart) return;
event.preventDefault();
this.ensureVideoRect();
if (!this.state.videoRect) return;
const sensitivity = 2;
const deltaX = touch.clientX - this.state.touchpadStart.x;
const deltaY = touch.clientY - this.state.touchpadStart.y;
const newX =
this.state.touchpadStart.normalizedX +
(deltaX / this.state.videoRect.width) * sensitivity;
const newY =
this.state.touchpadStart.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,
});
}
onTouchpadEnd(event) {
event.preventDefault();
this.state.touchpadStart = null;
}
bindVirtualMouseDragging() {
const { virtualMouse, virtualDragHandle, videoContainer } = this.elements;
if (!virtualMouse || !virtualDragHandle || !videoContainer) return;
virtualDragHandle.addEventListener("touchstart", this.onDragHandleTouchStart, {
passive: false,
});
virtualDragHandle.addEventListener("click", this.onDragHandleClick);
document.addEventListener("touchmove", this.onDragHandleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this.onDragHandleTouchEnd, {
passive: false,
});
document.addEventListener("touchcancel", this.onDragHandleTouchEnd, {
passive: false,
});
}
onDragHandleTouchStart(event) {
const touch = event.touches?.[0];
if (!touch || !this.elements.virtualMouse) return;
event.preventDefault();
const rect = this.elements.virtualMouse.getBoundingClientRect();
this.state.draggingVirtualMouse = true;
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));
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;
}
onDragHandleClick(event) {
event.stopPropagation();
this.elements.virtualMouse?.classList.toggle("minimized");
}
setupFullscreenButtons() {
this.elements.fullscreenBtn?.addEventListener("click", () => {
const media = this.elements.mediaContainer;
if (!media) return;
this.state.isFullscreen = !this.state.isFullscreen;
media.classList.toggle("fullscreen", this.state.isFullscreen);
this.elements.fullscreenBtn.textContent = this.state.isFullscreen
? "退出全屏"
: "最大化";
this.ensureVideoRect();
});
this.elements.realFullscreenBtn?.addEventListener("click", () => {
const container = this.elements.videoContainer;
if (!container) return;
if (!this.state.isRealFullscreen) {
const request =
container.requestFullscreen ||
container.mozRequestFullScreen ||
container.webkitRequestFullscreen ||
container.msRequestFullscreen;
request?.call(container);
} else {
const exit =
document.exitFullscreen ||
document.mozCancelFullScreen ||
document.webkitExitFullscreen ||
document.msExitFullscreen;
exit?.call(document);
}
this.state.isRealFullscreen = !this.state.isRealFullscreen;
this.elements.realFullscreenBtn.textContent = this.state.isRealFullscreen
? "退出全屏"
: "全屏";
});
}
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;
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;
}
}
}
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);
})();