function cobrowse() { 'use strict'; // ========================================================== // Настройки // ========================================================== const CONSOLE_MAX_LINES = 500; // сколько строк держать в консоли пользователя const CLICK_PULSE_MS = 600; // сколько живёт вспышка клика в DOM (мс) // Авто-скролл: если курсор клиента приближается к краю видимой // области #replayContainer, плавно подъезжаем. const AUTO_SCROLL_MARGIN = 80; // px от края, при которых срабатывает const AUTO_SCROLL_SPEED = 'smooth'; // 'smooth' | 'auto' // const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') // + location.host + '/support/ws/'; const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + 'dev1.drugie-reshenia.ru/support/ws/'; // ========================================================== // UI // ========================================================== const codeInput = document.getElementById('sessionCodeInput'); const connectBtn = document.getElementById('connectBtn'); const disconnectBtn = document.getElementById('disconnectBtn'); const statusEl = document.getElementById('status'); const statusTextEl = document.getElementById('statusText'); const replayEl = document.getElementById('replayContainer'); const stageEl = document.getElementById('replayStage'); const clickLayer = document.getElementById('clickLayer'); const consoleEl = document.getElementById('consoleLog'); const consoleCard = document.getElementById('consoleCard'); const toggleConsoleBtn = document.getElementById('toggleConsoleBtn'); const clearConsoleBtn = document.getElementById('clearConsoleBtn'); const statEventsEl = document.getElementById('statEvents'); const statClicksEl = document.getElementById('statClicks'); const statConsoleEl = document.getElementById('statConsole'); const statViewportEl = document.getElementById('statViewport'); let ws = null; let replayer = null; let recordedViewport = { width: 0, height: 0 }; let sessionCode = null; let eventCount = 0; let clickCount = 0; let consoleCount = 0; function setStatus(online, text) { statusEl.classList.toggle('online', !!online); statusTextEl.textContent = text; } function updateStats() { statEventsEl.textContent = eventCount; statClicksEl.textContent = clickCount; statConsoleEl.textContent = consoleCount; statViewportEl.textContent = recordedViewport.width ? `${recordedViewport.width}×${recordedViewport.height}` : '—'; } // ========================================================== // Кнопки скрытия/показа // ========================================================== toggleConsoleBtn.addEventListener('click', () => { const hidden = consoleCard.classList.toggle('hidden'); toggleConsoleBtn.textContent = hidden ? 'Показать консоль' : 'Скрыть консоль'; }); clearConsoleBtn.addEventListener('click', () => { consoleEl.innerHTML = ''; consoleBuffer.linesInDom = 0; }); // ========================================================== // Консоль пользователя // ========================================================== const consoleBuffer = { linesInDom: 0 }; function appendLogLine(target, text, maxLines, extraClass) { const line = document.createElement('div'); line.className = 'line' + (extraClass ? ' ' + extraClass : ''); line.textContent = text; const atBottom = target.scrollTop + target.clientHeight >= target.scrollHeight - 4; target.appendChild(line); while (target.childNodes.length > maxLines && target.firstChild) { target.removeChild(target.firstChild); } if (atBottom) target.scrollTop = target.scrollHeight; } function appendConsoleEntry(entry) { const ts = new Date(entry.ts || Date.now()).toLocaleTimeString(); const level = entry.level === 'error' ? 'error' : 'log'; const text = `[${ts}] [${level}] ${(entry.args || []).join(' ')}`; appendLogLine(consoleEl, text, CONSOLE_MAX_LINES, 'level-' + level); consoleBuffer.linesInDom = Math.min(consoleBuffer.linesInDom + 1, CONSOLE_MAX_LINES); } // ========================================================== // «Сцена» — размеры 1:1 с viewport клиента // ========================================================== function applyRecordedViewport() { if (!recordedViewport.width || !recordedViewport.height) return; stageEl.style.width = recordedViewport.width + 'px'; stageEl.style.height = recordedViewport.height + 'px'; updateStats(); } // ========================================================== // Плеер rrweb (внутри #replayStage) // ========================================================== function destroyPlayer() { if (replayer) { try { replayer.pause(); } catch {} replayer = null; } // Удаляем всё, что rrweb добавил внутрь stage, кроме clickLayer [...stageEl.children].forEach((el) => { if (el !== clickLayer) stageEl.removeChild(el); }); clickLayer.innerHTML = ''; pointerEl = null; // Сбрасываем размеры stage к «авто», чтобы не осталось // артефактов от предыдущей сессии stageEl.style.width = '100%'; stageEl.style.height = '100%'; eventCount = 0; clickCount = 0; consoleCount = 0; consoleBuffer.linesInDom = 0; updateStats(); } function createPlayer(events, startAtLastTs) { const overlay = document.createElement('div'); overlay.id = 'overlay'; if (!events || events.length === 0) { replayer = new rrweb.Replayer([], { root: stageEl, liveMode: true, showDebug: false, speed: 1, mouseTail: false, }); replayer.startLive(); // Сразу после создания назначаем id для внутреннего iframe if (replayer.iframe) { replayer.iframe.id = 'replay-iframe'; replayer.iframe.insertAdjacentElement('afterend', overlay); startDrag(overlay); } return; } replayer = new rrweb.Replayer(events, { root: stageEl, liveMode: true, showDebug: false, speed: 1, mouseTail: false, }); // Сразу после создания назначаем id для внутреннего iframe if (replayer.iframe) { replayer.iframe.id = 'replay-iframe'; replayer.iframe.insertAdjacentElement('afterend', overlay); startDrag(overlay); } // Задеркжа отрисовки const baseline = startAtLastTs ? events[events.length - 1].timestamp - 100 : events[0].timestamp; replayer.startLive(baseline); } /* ---- 2. Drag-scroll ---- */ function startDrag(overlay) { let down = false, x0, y0, l0, t0; overlay.onmousedown = (e) => { down = true; x0 = e.clientX; y0 = e.clientY; l0 = replayEl.scrollLeft; t0 = replayEl.scrollTop; e.preventDefault(); }; window.onmousemove = (e) => { if (!down) return; replayEl.scrollLeft = l0 - (e.clientX - x0); replayEl.scrollTop = t0 - (e.clientY - y0); }; window.onmouseup = () => { down = false; }; } // ========================================================== // Курсор и вспышки кликов // ========================================================== // Теперь координаты клиента и координаты внутри #replayStage // совпадают 1:1 (потому что stage имеет размер viewport клиента). let pointerEl = null; function ensurePointerEl() { if (!pointerEl || !pointerEl.isConnected) { pointerEl = document.createElement('div'); pointerEl.className = 'client-pointer'; clickLayer.appendChild(pointerEl); } return pointerEl; } // Гарантирует, что точка (clientX, clientY) в координатах клиента // находится в видимой области #replayContainer. // Если нет — плавно скроллит контейнер. function ensurePointerVisible(clientX, clientY) { // Позиция точки в координатах viewport контейнера = координата // внутри stage минус текущий скролл. const scrollLeft = replayEl.scrollLeft; const scrollTop = replayEl.scrollTop; const viewW = replayEl.clientWidth; const viewH = replayEl.clientHeight; // Координата точки внутри видимой области контейнера const xInView = clientX - scrollLeft; const yInView = clientY - scrollTop; const margin = AUTO_SCROLL_MARGIN; let targetLeft = scrollLeft; let targetTop = scrollTop; // Горизонталь if (xInView < margin) { // Курсор близко к левому краю — скроллим влево targetLeft = Math.max(0, clientX - margin); } else if (xInView > viewW - margin) { // Близко к правому краю — скроллим вправо targetLeft = Math.min( clientX - viewW + margin, stageEl.scrollWidth - viewW ); } // Вертикаль if (yInView < margin) { targetTop = Math.max(0, clientY - margin); } else if (yInView > viewH - margin) { targetTop = Math.min( clientY - viewH + margin, stageEl.scrollHeight - viewH ); } if (targetLeft !== scrollLeft || targetTop !== scrollTop) { replayEl.scrollTo({ left: targetLeft, top: targetTop, behavior: AUTO_SCROLL_SPEED, }); } } function updatePointer(clientX, clientY) { const el = ensurePointerEl(); el.style.transform = `translate(${clientX}px, ${clientY}px)`; ensurePointerVisible(clientX, clientY); } function showClickAt(clientX, clientY) { const pulse = document.createElement('div'); pulse.className = 'click-pulse'; pulse.style.left = clientX + 'px'; pulse.style.top = clientY + 'px'; // На всякий случай гарантируем, что место клика видно оператору ensurePointerVisible(clientX, clientY); clickLayer.appendChild(pulse); const cross = document.createElement('div'); cross.className = 'click-cross'; cross.style.left = clientX + 'px'; cross.style.top = clientY + 'px'; clickLayer.appendChild(cross); setTimeout(() => { pulse.remove(); cross.remove(); }, CLICK_PULSE_MS); clickCount++; updateStats(); } // ========================================================== // WebSocket оператора // ========================================================== function connectToSession() { const code = (codeInput.value || '').trim().toUpperCase(); // Новый формат: 6 цифр. Старый оставляем на переходный период. if (!/^\d{6}$/.test(code) && !/^[A-Z0-9]{4,16}$/.test(code)) { alert('Некорректный код сессии'); return; } sessionCode = code; if (ws) { try { ws.close(1000, 'reconnect'); } catch {} ws = null; } destroyPlayer(); setStatus(false, 'подключение…'); ws = new WebSocket(WS_URL); ws.addEventListener('open', () => { setStatus(true, 'подключено к ' + sessionCode); ws.send(JSON.stringify({ type: 'join-session', sessionCode })); }); ws.addEventListener('message', (e) => { let msg; try { msg = JSON.parse(e.data); } catch { return; } // ---------- История ---------- if (msg.type === 'session-history') { const events = msg.events || []; const consoleLogs = msg.consoleLogs || []; for (const ev of events) { if (ev.type === 4 && ev.data) { recordedViewport.width = ev.data.width; recordedViewport.height = ev.data.height; break; } } applyRecordedViewport(); let lastPointerFromHistory = null; for (let i = events.length - 1; i >= 0; i--) { const ev = events[i]; if (ev.type === 3 && ev.data && ev.data.source === 1) { const positions = ev.data.positions; const p = (positions && positions.length) ? positions[positions.length - 1] : ev.data; if (typeof p.x === 'number' && typeof p.y === 'number') { lastPointerFromHistory = { x: p.x, y: p.y }; } break; } } createPlayer(events, true); if (lastPointerFromHistory) { requestAnimationFrame(() => requestAnimationFrame(() => { updatePointer(lastPointerFromHistory.x, lastPointerFromHistory.y); })); } consoleEl.innerHTML = ''; consoleBuffer.linesInDom = 0; consoleCount = 0; for (const entry of consoleLogs) { appendConsoleEntry(entry); consoleCount++; } updateStats(); } // ---------- rrweb-событие ---------- else if (msg.type === 'rrweb-event') { const ev = msg.event; if (ev.type === 4 && ev.data) { const changed = (recordedViewport.width !== ev.data.width) || (recordedViewport.height !== ev.data.height); recordedViewport.width = ev.data.width; recordedViewport.height = ev.data.height; if (changed) applyRecordedViewport(); } if (!replayer) { createPlayer([ev], true); } else { replayer.addEvent(ev); } eventCount++; if (ev.type === 3 && ev.data) { const src = ev.data.source; const t = ev.data.type; if (src === 1) { const positions = ev.data.positions; if (positions && positions.length) { const p = positions[positions.length - 1]; updatePointer(p.x, p.y); } else if (typeof ev.data.x === 'number') { updatePointer(ev.data.x, ev.data.y); } } const isMouse = src === 2 && (t === 2 || t === 3 || t === 4); const isTouch = src === 6 && (t === 9 || t === 10); if (isMouse || isTouch) { showClickAt(ev.data.x || 0, ev.data.y || 0); updatePointer(ev.data.x || 0, ev.data.y || 0); } } } // ---------- Консоль ---------- else if (msg.type === 'console-event') { appendConsoleEntry(msg); consoleCount++; updateStats(); } else if (msg.type === 'error') { } }); ws.addEventListener('close', (ev) => { setStatus(false, 'отключено'); }); connectBtn.disabled = true; disconnectBtn.disabled = false; } function disconnect() { if (ws) { try { ws.close(1000, 'operator disconnect'); } catch {} ws = null; } destroyPlayer(); setStatus(false, 'не подключено'); connectBtn.disabled = false; disconnectBtn.disabled = true; } connectBtn.addEventListener('click', connectToSession); disconnectBtn.addEventListener('click', disconnect); codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !connectBtn.disabled) connectToSession(); }); };