feat(cdp): Phase 2 - real-time UI mirroring via screencast + remote input
This commit is contained in:
@@ -6,9 +6,11 @@
|
||||
// ─── 컴포넌트 초기화 ──────────────────────────────────
|
||||
const sessionPanel = new SessionPanel();
|
||||
const chatPanel = new ChatPanel();
|
||||
const mirrorPanel = new MirrorPanel();
|
||||
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let currentView = 'chat'; // 'chat' | 'mirror'
|
||||
const WS_URL = `ws://${location.host}/ws`;
|
||||
|
||||
// ─── DOM 레퍼런스 ─────────────────────────────────────
|
||||
@@ -31,6 +33,12 @@
|
||||
const screenshotImage = document.getElementById('screenshotImage');
|
||||
const closeScreenshot = document.getElementById('closeScreenshot');
|
||||
|
||||
const tabChat = document.getElementById('tabChat');
|
||||
const tabMirror = document.getElementById('tabMirror');
|
||||
const chatMessagesWrap = document.getElementById('chatMessages');
|
||||
const chatInputArea = document.querySelector('.chat-input-area');
|
||||
const mirrorHint = document.getElementById('mirrorHint');
|
||||
|
||||
// ─── WebSocket 연결 ───────────────────────────────────
|
||||
function connectWebSocket() {
|
||||
ws = new WebSocket(WS_URL);
|
||||
@@ -105,6 +113,22 @@
|
||||
screenshotOverlay.style.display = 'flex';
|
||||
break;
|
||||
|
||||
case 'screencast_frame':
|
||||
if (msg.sessionId === sessionPanel.activeSessionId) {
|
||||
mirrorPanel.updateFrame(msg.data, msg.metadata);
|
||||
if (mirrorHint) mirrorHint.style.display = 'none';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'screencast_started':
|
||||
if (!msg.success) {
|
||||
showToast('Screencast 시작 실패', 'error');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'screencast_stopped':
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
showToast(msg.message, 'error');
|
||||
break;
|
||||
@@ -140,6 +164,42 @@
|
||||
});
|
||||
};
|
||||
|
||||
// ─── 미러 패널 이벤트 ─────────────────────────────
|
||||
mirrorPanel.onStartScreencast = (sessionId) => {
|
||||
sendWs({ type: 'start_screencast', sessionId });
|
||||
};
|
||||
|
||||
mirrorPanel.onStopScreencast = (sessionId) => {
|
||||
sendWs({ type: 'stop_screencast', sessionId });
|
||||
};
|
||||
|
||||
mirrorPanel.onInputEvent = (sessionId, event) => {
|
||||
sendWs({ type: 'input_event', sessionId, event });
|
||||
};
|
||||
|
||||
// ─── 탭 전환 (Chat ↔ Mirror) ────────────────────────
|
||||
function switchView(view) {
|
||||
currentView = view;
|
||||
|
||||
// 탭 UI
|
||||
tabChat.classList.toggle('active', view === 'chat');
|
||||
tabMirror.classList.toggle('active', view === 'mirror');
|
||||
|
||||
// 채팅 영역 표시/숨기기
|
||||
chatMessagesWrap.style.display = view === 'chat' ? '' : 'none';
|
||||
if (chatInputArea) chatInputArea.style.display = view === 'chat' ? '' : 'none';
|
||||
|
||||
if (view === 'mirror') {
|
||||
if (mirrorHint) mirrorHint.style.display = '';
|
||||
mirrorPanel.start(sessionPanel.activeSessionId);
|
||||
} else {
|
||||
mirrorPanel.stop();
|
||||
}
|
||||
}
|
||||
|
||||
tabChat?.addEventListener('click', () => switchView('chat'));
|
||||
tabMirror?.addEventListener('click', () => switchView('mirror'));
|
||||
|
||||
// ─── 모달 ─────────────────────────────────────────────
|
||||
function showModal() {
|
||||
addSessionModal.style.display = 'flex';
|
||||
|
||||
218
public/js/mirror-panel.js
Normal file
218
public/js/mirror-panel.js
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Mirror Panel — Canvas 기반 실시간 UI 미러링
|
||||
*
|
||||
* Screencast 프레임을 canvas에 렌더링하고
|
||||
* 마우스/키보드/스크롤 이벤트를 서버로 전달
|
||||
*/
|
||||
|
||||
class MirrorPanel {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('mirrorCanvas');
|
||||
this.ctx = this.canvas ? this.canvas.getContext('2d') : null;
|
||||
this.containerEl = document.getElementById('mirrorContainer');
|
||||
|
||||
this.active = false;
|
||||
this.sessionId = null;
|
||||
|
||||
// 실제 대상 페이지 크기 (metadata에서 가져옴)
|
||||
this.deviceWidth = 0;
|
||||
this.deviceHeight = 0;
|
||||
|
||||
// 콜백
|
||||
this.onStartScreencast = null; // callback(sessionId)
|
||||
this.onStopScreencast = null; // callback(sessionId)
|
||||
this.onInputEvent = null; // callback(sessionId, event)
|
||||
|
||||
// 이미지 로딩용
|
||||
this._img = new Image();
|
||||
this._img.onload = () => {
|
||||
if (!this.ctx) return;
|
||||
this.canvas.width = this._img.naturalWidth;
|
||||
this.canvas.height = this._img.naturalHeight;
|
||||
this.ctx.drawImage(this._img, 0, 0);
|
||||
};
|
||||
|
||||
// 마우스 이벤트 쓸모링
|
||||
this._lastMouseMove = 0;
|
||||
this._MOUSE_THROTTLE = 50; // ms
|
||||
|
||||
this._setupEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 바인딩
|
||||
*/
|
||||
_setupEvents() {
|
||||
if (!this.canvas) return;
|
||||
|
||||
// --- 마우스 ---
|
||||
this.canvas.addEventListener('mousedown', (e) => {
|
||||
if (!this.active) return;
|
||||
const pos = this._toDeviceCoords(e);
|
||||
this._sendMouse('mousePressed', pos, e);
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('mouseup', (e) => {
|
||||
if (!this.active) return;
|
||||
const pos = this._toDeviceCoords(e);
|
||||
this._sendMouse('mouseReleased', pos, e);
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('mousemove', (e) => {
|
||||
if (!this.active) return;
|
||||
const now = Date.now();
|
||||
if (now - this._lastMouseMove < this._MOUSE_THROTTLE) return;
|
||||
this._lastMouseMove = now;
|
||||
const pos = this._toDeviceCoords(e);
|
||||
this._sendMouse('mouseMoved', pos, e);
|
||||
});
|
||||
|
||||
// --- 스크롤 ---
|
||||
this.canvas.addEventListener('wheel', (e) => {
|
||||
if (!this.active) return;
|
||||
e.preventDefault();
|
||||
const pos = this._toDeviceCoords(e);
|
||||
this._sendInput({
|
||||
type: 'scroll',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
deltaX: e.deltaX,
|
||||
deltaY: e.deltaY,
|
||||
});
|
||||
}, { passive: false });
|
||||
|
||||
// --- 키보드 ---
|
||||
this.canvas.setAttribute('tabindex', '0');
|
||||
|
||||
this.canvas.addEventListener('keydown', (e) => {
|
||||
if (!this.active) return;
|
||||
e.preventDefault();
|
||||
this._sendKey('keyDown', e);
|
||||
// char 이벤트도 보내야 텍스트 입력이 됨
|
||||
if (e.key.length === 1) {
|
||||
this._sendKey('char', e);
|
||||
}
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('keyup', (e) => {
|
||||
if (!this.active) return;
|
||||
e.preventDefault();
|
||||
this._sendKey('keyUp', e);
|
||||
});
|
||||
|
||||
// canvas 클릭 시 포커스
|
||||
this.canvas.addEventListener('click', () => {
|
||||
this.canvas.focus();
|
||||
});
|
||||
|
||||
// 우클릭 방지
|
||||
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
}
|
||||
|
||||
/**
|
||||
* 미러링 시작
|
||||
*/
|
||||
start(sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
this.active = true;
|
||||
if (this.containerEl) this.containerEl.style.display = 'flex';
|
||||
if (this.onStartScreencast) this.onStartScreencast(sessionId);
|
||||
this.canvas?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 미러링 중지
|
||||
*/
|
||||
stop() {
|
||||
if (this.active && this.onStopScreencast) {
|
||||
this.onStopScreencast(this.sessionId);
|
||||
}
|
||||
this.active = false;
|
||||
this.sessionId = null;
|
||||
if (this.containerEl) this.containerEl.style.display = 'none';
|
||||
// canvas 초기화
|
||||
if (this.ctx && this.canvas) {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Screencast 프레임 수신
|
||||
*/
|
||||
updateFrame(data, metadata) {
|
||||
if (!this.active) return;
|
||||
|
||||
if (metadata) {
|
||||
this.deviceWidth = metadata.deviceWidth || this.deviceWidth;
|
||||
this.deviceHeight = metadata.deviceHeight || this.deviceHeight;
|
||||
}
|
||||
|
||||
// base64 JPEG를 이미지로 로드 → canvas에 그리기
|
||||
this._img.src = `data:image/jpeg;base64,${data}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* canvas 좌표 → 디바이스(Antigravity) 좌표 변환
|
||||
*/
|
||||
_toDeviceCoords(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const scaleX = this.canvas.width / rect.width;
|
||||
const scaleY = this.canvas.height / rect.height;
|
||||
return {
|
||||
x: Math.round((e.clientX - rect.left) * scaleX),
|
||||
y: Math.round((e.clientY - rect.top) * scaleY),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 마우스 이벤트 전송
|
||||
*/
|
||||
_sendMouse(action, pos, e) {
|
||||
const buttonMap = { 0: 'left', 1: 'middle', 2: 'right' };
|
||||
this._sendInput({
|
||||
type: 'mouse',
|
||||
action,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
button: buttonMap[e.button] || 'left',
|
||||
clickCount: action === 'mousePressed' ? 1 : 0,
|
||||
modifiers: this._getModifiers(e),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 키보드 이벤트 전송
|
||||
*/
|
||||
_sendKey(action, e) {
|
||||
this._sendInput({
|
||||
type: 'key',
|
||||
action,
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
text: action === 'char' ? e.key : '',
|
||||
keyCode: e.keyCode,
|
||||
modifiers: this._getModifiers(e),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 이벤트 전송 (공통)
|
||||
*/
|
||||
_sendInput(event) {
|
||||
if (this.onInputEvent && this.sessionId) {
|
||||
this.onInputEvent(this.sessionId, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정키 비트마스크 계산 (CDP 규격)
|
||||
*/
|
||||
_getModifiers(e) {
|
||||
let m = 0;
|
||||
if (e.altKey) m |= 1;
|
||||
if (e.ctrlKey) m |= 2;
|
||||
if (e.metaKey) m |= 4;
|
||||
if (e.shiftKey) m |= 8;
|
||||
return m;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user