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
+52 -3
View File
@@ -672,6 +672,8 @@ window.LS = {
parentGetLinks, parentCreateLink, parentUpdateLink, parentDeleteLink,
crCreateSession, crGetSession, crEndSession, crGetActiveByClass, crGetMyActive,
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
crAdminGetAllHistory, crAdminGetTeachersList,
escapeHtml, esc,
parseDate, fmtRelTime, safeHref,
initPage,
@@ -985,6 +987,28 @@ async function crGetChat(id) { return req('GET', `/classroom/
async function crGetAttendance(id) { return req('GET', `/classroom/${id}/attendance`); }
async function crSignal(id, targetUserId, payload) { return req('POST', `/classroom/${id}/signal`, { target_user_id: targetUserId, payload }); }
async function crGetOnlineStudents() { return req('GET', '/classroom/online-students'); }
async function crGetMyHistory(page = 1) { return req('GET', `/classroom/my/history?page=${page}`); }
async function crGetClassHistory(classId, page = 1, search = '') {
const q = search ? `&search=${encodeURIComponent(search)}` : '';
return req('GET', `/classroom/class/${classId}/history?page=${page}${q}`);
}
async function crGetSessionSummary(id) { return req('GET', `/classroom/${id}/summary`); }
async function crExportChatUrl(id) { return `/api/classroom/${id}/chat/export`; }
async function crGetAllNotes(id) { return req('GET', `/classroom/${id}/notes/all`); }
async function crDeleteHistory(id) { return req('DELETE', `/classroom/${id}/history`); }
async function crAdminGetAllHistory(p = {}) {
const q = new URLSearchParams();
if (p.page) q.set('page', p.page);
if (p.limit) q.set('limit', p.limit);
if (p.search) q.set('search', p.search);
if (p.teacher) q.set('teacher', p.teacher);
if (p.class_id) q.set('class_id', p.class_id);
if (p.date_from) q.set('date_from', p.date_from);
if (p.date_to) q.set('date_to', p.date_to);
if (p.sort) q.set('sort', p.sort);
return req('GET', `/classroom/admin/sessions?${q}`);
}
async function crAdminGetTeachersList() { return req('GET', '/classroom/admin/teachers-list'); }
/* ── gamification admin ────────────────────────────────────────────────── */
async function adminGamAward(data) { return req('POST', '/gamification/admin/award', data); }
@@ -1046,6 +1070,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
styleEl.textContent = STYLE;
document.addEventListener('DOMContentLoaded', () => {
if (window._lsLiveOverriddenByClassroom) return;
document.head.appendChild(styleEl);
document.body.appendChild(el);
});
@@ -1053,15 +1078,38 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
let currentLiveId = null;
let answered = false;
/* render text that may contain \(...\) or \[...\] LaTeX using window.katex */
function _mathHtml(text) {
if (!text) return '';
const kat = window.katex;
if (!kat) { const d = document.createElement('span'); d.textContent = text; return d.innerHTML; }
let out = '', i = 0;
while (i < text.length) {
const ii = text.indexOf('\\(', i), bi = text.indexOf('\\[', i);
let next = -1, close = '', disp = false;
if (ii >= 0 && (bi < 0 || ii <= bi)) { next = ii; close = '\\)'; disp = false; }
else if (bi >= 0) { next = bi; close = '\\]'; disp = true; }
const plain = document.createElement('span');
if (next < 0) { plain.textContent = text.slice(i); out += plain.innerHTML; break; }
plain.textContent = text.slice(i, next); out += plain.innerHTML;
const ci = text.indexOf(close, next + 2);
if (ci < 0) { const p2 = document.createElement('span'); p2.textContent = text.slice(next); out += p2.innerHTML; break; }
try { out += kat.renderToString(text.slice(next + 2, ci), { displayMode: disp, throwOnError: false }); }
catch { const p2 = document.createElement('span'); p2.textContent = text.slice(next, ci + close.length); out += p2.innerHTML; }
i = ci + close.length;
}
return out;
}
function openOverlay(liveId, question, options) {
currentLiveId = liveId;
answered = false;
document.getElementById('lslq-text').textContent = question.text;
document.getElementById('lslq-text').innerHTML = _mathHtml(question.text);
const keys = 'АБВГДЕ';
document.getElementById('lslq-opts').innerHTML = (options || []).map((o, i) => `
<div class="ls-live-opt" data-id="${o.id}" onclick="window._lsLiveAnswer(${liveId},${o.id},this)">
<span class="ls-live-opt-key">${keys[i] || i+1}</span>
<span>${esc(o.text)}</span>
<span>${_mathHtml(o.text)}</span>
</div>`).join('');
document.getElementById('lslq-status').textContent = 'Выберите ответ';
document.getElementById('ls-live-overlay').classList.add('open');
@@ -1079,7 +1127,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
return `<div class="ls-live-opt ${cls}" style="flex-direction:column;align-items:flex-start;gap:4px">
<div style="display:flex;align-items:center;gap:10px;width:100%">
<span class="ls-live-opt-key">${keys[i]||i+1}</span>
<span style="flex:1">${esc(o.text)}</span>
<span style="flex:1">${_mathHtml(o.text)}</span>
<span class="ls-live-result-pct">${pct}%</span>
</div>
<div class="ls-live-result-bar" style="width:100%">
@@ -1103,6 +1151,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
};
document.addEventListener('DOMContentLoaded', () => {
if (window._lsLiveOverriddenByClassroom) return;
connectSSE(d => {
if (d.type === 'live_question') {
openOverlay(d.liveId, d.question, d.question?.options);