feat: WebSocket real-time + rAF render gate + guest board + screen picker

Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-13 18:04:59 +03:00
parent 074ee5687b
commit fd29acbbdd
70 changed files with 12231 additions and 498 deletions
+24 -4
View File
@@ -8,6 +8,7 @@
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<style>
.sb-content { background: #f4f5f8; overflow: hidden; display: flex; flex-direction: column; }
@@ -243,6 +244,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link nav-active"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
@@ -382,6 +384,8 @@
</div>
<script src="/js/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
@@ -409,6 +413,22 @@
};
const SUBJECT_NAMES = { bio: 'Биология', chem: 'Химия', math: 'Математика', phys: 'Физика', other: 'Другое' };
/* ── math rendering ── */
const _MATH_DELIMS = [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
];
function mathHtml(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = text;
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: _MATH_DELIMS, throwOnError: false }); } catch {}
}
return tmp.innerHTML;
}
/* ── sidebar ── */
function toggleSidebar() {
const layout = document.querySelector('.app-layout');
@@ -570,7 +590,7 @@
<span class="badge ${diffCls}">${diffLabel}</span>
<span class="badge badge-type">${esc(typeLabel)}</span>
</div>
<div class="qc-text">${esc(q.text)}</div>
<div class="qc-text">${mathHtml(q.text)}</div>
<div class="qc-footer">
<span class="qc-topic"><i data-lucide="tag" style="width:11px;height:11px"></i> ${esc(q.topic || SUBJECT_NAMES[q.subject_slug] || '—')}</span>
${optsCount ? `<span class="qc-opts-count"><i data-lucide="list" style="width:11px;height:11px"></i> ${optsCount} вар.</span>` : ''}
@@ -578,20 +598,20 @@
if (isExpanded) {
html += `<div class="qc-preview">
<div class="qc-preview-text">${esc(q.text)}</div>`;
<div class="qc-preview-text">${mathHtml(q.text)}</div>`;
if ((q.options || []).length) {
html += '<div class="qc-options">';
q.options.forEach((opt, idx) => {
const letter = String.fromCharCode(65 + idx);
html += `<div class="qc-option${opt.is_correct ? ' correct' : ''}">
<div class="qc-option-marker">${opt.is_correct ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : letter}</div>
<span>${esc(opt.text)}</span>
<span>${mathHtml(opt.text)}</span>
</div>`;
});
html += '</div>';
}
if (q.explanation) {
html += `<div class="qc-explanation"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>`;
html += `<div class="qc-explanation"><strong>Пояснение:</strong> ${mathHtml(q.explanation)}</div>`;
}
html += '</div>';
}