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
+20 -2
View File
@@ -7,6 +7,7 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<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" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<style>
.sb-content { background: #f4f5f8; min-height: 100vh; }
.fc-wrap { max-width: 1100px; margin: 0 auto; padding: 28px 28px 80px; }
@@ -395,6 +396,8 @@
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/search.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>
(async () => {
/* ── auth ── */
@@ -664,13 +667,28 @@ async function startStudyForDeck(deckId) {
bindSwipe();
}
const _FC_DELIMS = [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
];
function mathHtmlFC(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = text;
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: _FC_DELIMS, throwOnError: false }); } catch {}
}
return tmp.innerHTML;
}
function showStudyCard() {
const card = _studyCards[_studyIdx];
if (!card) { finishStudy(); return; }
const el = document.getElementById('study-card');
el.className = 'study-card-inner';
document.getElementById('study-front-text').textContent = card.front;
document.getElementById('study-back-text').textContent = card.back;
document.getElementById('study-front-text').innerHTML = mathHtmlFC(card.front);
document.getElementById('study-back-text').innerHTML = mathHtmlFC(card.back);
_studyFlipped = false;
document.getElementById('study-btns').classList.remove('visible');
document.getElementById('study-flip-hint').style.display = 'block';