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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user