feat(cdp): Phase 2 - real-time UI mirroring via screencast + remote input

This commit is contained in:
2026-03-07 20:41:53 +09:00
parent 9234be33db
commit 4b855c9e57
6 changed files with 526 additions and 1 deletions

View File

@@ -610,3 +610,72 @@ html, body {
.toast.error { border-color: var(--error); }
.toast.success { border-color: var(--success); }
/* ─── View Tabs (Chat ↔ Mirror) ──────────────────────── */
.view-tabs {
display: flex;
gap: 2px;
background: var(--bg-tertiary);
border-radius: 8px;
padding: 2px;
}
.view-tab {
padding: 4px 12px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.view-tab:hover {
color: var(--text-secondary);
}
.view-tab.active {
background: var(--accent-primary);
color: white;
box-shadow: 0 1px 4px rgba(99, 102, 241, 0.3);
}
/* ─── Mirror Container ───────────────────────────────── */
.mirror-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
.mirror-container canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
cursor: default;
outline: none;
border-radius: 4px;
}
.mirror-container canvas:focus {
box-shadow: 0 0 0 2px var(--accent-primary);
}
.mirror-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-muted);
font-size: 14px;
pointer-events: none;
animation: pulse 1.5s infinite;
}

View File

@@ -75,6 +75,10 @@
<span class="chat-session-status" id="chatSessionStatus"></span>
</div>
<div class="chat-actions">
<div class="view-tabs" id="viewTabs">
<button class="view-tab active" data-view="chat" id="tabChat">💬 채팅</button>
<button class="view-tab" data-view="mirror" id="tabMirror">🖥️ 미러</button>
</div>
<button class="btn-sm" id="screenshotBtn" title="스크린샷">📷</button>
<button class="btn-sm" id="reconnectBtn" title="재연결">🔄</button>
</div>
@@ -96,6 +100,12 @@
</svg>
</button>
</div>
<!-- Mirror view (Phase 2) -->
<div class="mirror-container" id="mirrorContainer" style="display:none;">
<canvas id="mirrorCanvas"></canvas>
<div class="mirror-hint" id="mirrorHint">미러링 연결 중...</div>
</div>
</div>
<!-- Screenshot overlay -->
@@ -145,6 +155,7 @@
<script src="js/session-panel.js"></script>
<script src="js/chat-panel.js"></script>
<script src="js/mirror-panel.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View File

@@ -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
View 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;
}
}