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:
+178
-47
@@ -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; }
|
||||
|
||||
@@ -135,10 +136,52 @@
|
||||
|
||||
/* question pick cards */
|
||||
.lq-q-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px; }
|
||||
/* question filters */
|
||||
.lq-filter-row { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||
.lq-filter-select {
|
||||
flex: 1; padding: 8px 10px;
|
||||
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 500;
|
||||
color: #0F172A; background: #fff; outline: none; cursor: pointer;
|
||||
transition: border-color 0.15s; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238898AA' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 8px center; padding-right: 24px;
|
||||
}
|
||||
.lq-filter-select:focus { border-color: var(--violet); }
|
||||
.lq-q-count { font-size: 0.7rem; color: #8898AA; font-weight: 600; white-space: nowrap; margin-bottom: 12px; }
|
||||
|
||||
/* load more */
|
||||
.btn-load-more {
|
||||
width: 100%; padding: 10px; border: 1.5px dashed rgba(155,93,229,0.3);
|
||||
border-radius: 12px; background: transparent; margin-bottom: 24px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
|
||||
color: var(--violet); cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.btn-load-more:hover { background: rgba(155,93,229,0.05); border-color: var(--violet); }
|
||||
.btn-load-more:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* result stat cards */
|
||||
.lq-result-stats { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.lq-result-stat { flex: 1; padding: 10px 12px; border-radius: 12px; background: rgba(15,23,42,0.04); text-align: center; }
|
||||
.lq-result-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 900; color: #0F172A; }
|
||||
.lq-result-stat-lbl { font-size: 0.64rem; color: #8898AA; margin-top: 2px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.lq-result-stat.rs-correct { background: rgba(6,214,160,0.08); }
|
||||
.lq-result-stat.rs-correct .lq-result-stat-val { color: #059652; }
|
||||
.lq-result-stat.rs-wrong { background: rgba(239,71,111,0.07); }
|
||||
.lq-result-stat.rs-wrong .lq-result-stat-val { color: #EF476F; }
|
||||
|
||||
/* explanation box */
|
||||
.lq-explanation {
|
||||
margin-top: 14px; padding: 13px 15px;
|
||||
background: rgba(6,214,224,0.06); border: 1.5px solid rgba(6,214,224,0.2);
|
||||
border-radius: 12px; font-size: 0.82rem; color: #0F172A; line-height: 1.6;
|
||||
}
|
||||
.lq-explanation-label { font-size: 0.64rem; font-weight: 700; color: #0891B2; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
|
||||
|
||||
.lq-q-card {
|
||||
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
||||
border-radius: 14px; padding: 14px 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
box-shadow: 0 1px 4px rgba(15,23,42,0.04); transition: all 0.15s;
|
||||
}
|
||||
.lq-q-card:hover { border-color: rgba(155,93,229,0.25); }
|
||||
@@ -146,9 +189,10 @@
|
||||
.lq-q-body { flex: 1; min-width: 0; }
|
||||
.lq-q-text {
|
||||
font-size: 0.84rem; font-weight: 600; color: #0F172A;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; line-height: 1.5; min-height: 1.5em;
|
||||
}
|
||||
.lq-q-meta { font-size: 0.7rem; color: #8898AA; margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.lq-q-meta { font-size: 0.7rem; color: #8898AA; margin-top: 5px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.btn-launch {
|
||||
padding: 7px 16px; border: none; border-radius: 999px;
|
||||
background: var(--grad-1); color: #fff;
|
||||
@@ -228,7 +272,7 @@
|
||||
}
|
||||
.lq-results-title {
|
||||
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
|
||||
color: #0F172A; margin-bottom: 14px; display: flex; align-items: center; gap: 8px;
|
||||
color: #0F172A; margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.result-bars { display: flex; flex-direction: column; gap: 10px; }
|
||||
.result-bar-row { display: flex; align-items: center; gap: 10px; }
|
||||
@@ -313,6 +357,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"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||||
@@ -421,12 +466,27 @@
|
||||
<i data-lucide="search" class="lq-search-icon"></i>
|
||||
<input class="lq-search" id="q-search" type="text" placeholder="Поиск вопросов…" />
|
||||
</div>
|
||||
<div class="lq-filter-row">
|
||||
<select class="lq-filter-select" id="topic-filter" onchange="onTopicFilter()">
|
||||
<option value="">Все темы</option>
|
||||
</select>
|
||||
<select class="lq-filter-select" id="diff-filter" onchange="onDiffFilter()" style="max-width:130px">
|
||||
<option value="">Любой уровень</option>
|
||||
<option value="1">Лёгкий</option>
|
||||
<option value="2">Средний</option>
|
||||
<option value="3">Сложный</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lq-q-count" id="q-count" style="display:none"></div>
|
||||
<div class="lq-q-list" id="q-list">
|
||||
<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">
|
||||
<div class="spinner" style="margin:0 auto 10px"></div>
|
||||
Загрузка вопросов…
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-load-more" id="btn-load-more" style="display:none" onclick="loadMoreQuestions()">
|
||||
Загрузить ещё
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,6 +495,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();
|
||||
|
||||
@@ -461,6 +523,22 @@
|
||||
'fill-blank': 'Заполни пробел', ordering: 'Порядок',
|
||||
};
|
||||
|
||||
/* ── 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');
|
||||
@@ -513,8 +591,12 @@
|
||||
let answerCount = 0;
|
||||
let sseSource = null;
|
||||
let allQuestions = [];
|
||||
let filteredQuestions = [];
|
||||
let searchTimeout = null;
|
||||
let _topicFilter = '';
|
||||
let _diffFilter = '';
|
||||
let _qPage = 0;
|
||||
let _totalQ = 0;
|
||||
const Q_LIMIT = 30;
|
||||
|
||||
/* ── load classes ── */
|
||||
async function loadClasses() {
|
||||
@@ -593,9 +675,24 @@
|
||||
document.getElementById('session-header-label').innerHTML =
|
||||
'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor" stroke="none"/></svg> Активна · ' + selectedClass.name;
|
||||
updateStudentCounter(0);
|
||||
loadTopics();
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
try {
|
||||
const topics = await LS.api('/api/topics');
|
||||
const sel = document.getElementById('topic-filter');
|
||||
sel.innerHTML = '<option value="">Все темы</option>';
|
||||
(topics || []).forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.id;
|
||||
opt.textContent = t.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function updateStudentCounter(count) {
|
||||
answerCount = count;
|
||||
document.getElementById('as-students-text').textContent =
|
||||
@@ -658,34 +755,56 @@
|
||||
}
|
||||
|
||||
/* ── load questions ── */
|
||||
async function loadQuestions() {
|
||||
document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA"><div class="spinner" style="margin:0 auto 10px"></div> Загрузка…</div>';
|
||||
async function loadQuestions(reset = true) {
|
||||
if (reset) { _qPage = 0; allQuestions = []; }
|
||||
if (reset) document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA"><div class="spinner" style="margin:0 auto 10px"></div> Загрузка…</div>';
|
||||
const params = new URLSearchParams({ limit: Q_LIMIT, offset: _qPage * Q_LIMIT });
|
||||
if (_topicFilter) params.set('topic_id', _topicFilter);
|
||||
if (_diffFilter) params.set('difficulty', _diffFilter);
|
||||
const sq = document.getElementById('q-search')?.value.trim();
|
||||
if (sq) params.set('search', sq);
|
||||
try {
|
||||
const data = await LS.api('/api/questions?limit=50');
|
||||
allQuestions = data.rows || [];
|
||||
filteredQuestions = allQuestions;
|
||||
const data = await LS.api('/api/questions?' + params.toString());
|
||||
const rows = data.rows || [];
|
||||
_totalQ = data.total ?? (reset ? rows.length : allQuestions.length + rows.length);
|
||||
allQuestions = reset ? rows : [...allQuestions, ...rows];
|
||||
renderQuestionList();
|
||||
const btnMore = document.getElementById('btn-load-more');
|
||||
const countEl = document.getElementById('q-count');
|
||||
if (btnMore) btnMore.style.display = allQuestions.length < _totalQ ? '' : 'none';
|
||||
if (countEl) { countEl.textContent = `Показано ${allQuestions.length} из ${_totalQ}`; countEl.style.display = ''; }
|
||||
} catch {
|
||||
document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки вопросов</div>';
|
||||
if (reset) document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки вопросов</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreQuestions() {
|
||||
const btn = document.getElementById('btn-load-more');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Загрузка…'; }
|
||||
_qPage++;
|
||||
await loadQuestions(false);
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Загрузить ещё'; }
|
||||
}
|
||||
|
||||
function onTopicFilter() { _topicFilter = document.getElementById('topic-filter').value; loadQuestions(true); }
|
||||
function onDiffFilter() { _diffFilter = document.getElementById('diff-filter').value; loadQuestions(true); }
|
||||
|
||||
function renderQuestionList() {
|
||||
const list = document.getElementById('q-list');
|
||||
if (!filteredQuestions.length) {
|
||||
if (!allQuestions.length) {
|
||||
list.innerHTML = '<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">Вопросов не найдено</div>';
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
filteredQuestions.forEach(q => {
|
||||
allQuestions.forEach(q => {
|
||||
const diffCls = 'badge-diff-' + (q.difficulty || 1);
|
||||
const diffLabel = q.difficulty === 1 ? 'Лёгкий' : q.difficulty === 2 ? 'Средний' : 'Сложный';
|
||||
const typeLabel = TYPE_LABELS[q.type] || q.type || '';
|
||||
const isLaunched = currentQuestion && currentQuestion.id === q.id;
|
||||
html += `<div class="lq-q-card${isLaunched ? ' launched' : ''}">
|
||||
<div class="lq-q-body">
|
||||
<div class="lq-q-text">${esc(q.text)}</div>
|
||||
<div class="lq-q-text" data-text="${esc(q.text)}"></div>
|
||||
<div class="lq-q-meta">
|
||||
<span class="badge ${diffCls}">${diffLabel}</span>
|
||||
${typeLabel ? `<span class="badge badge-type">${esc(typeLabel)}</span>` : ''}
|
||||
@@ -698,35 +817,28 @@
|
||||
</div>`;
|
||||
});
|
||||
list.innerHTML = html;
|
||||
list.querySelectorAll('.lq-q-text[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/* search questions */
|
||||
/* search questions — server-side */
|
||||
document.getElementById('q-search').addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const q = document.getElementById('q-search').value.trim().toLowerCase();
|
||||
filteredQuestions = q
|
||||
? allQuestions.filter(item => item.text.toLowerCase().includes(q) || (item.topic || '').toLowerCase().includes(q))
|
||||
: allQuestions;
|
||||
renderQuestionList();
|
||||
}, 280);
|
||||
searchTimeout = setTimeout(() => loadQuestions(true), 350);
|
||||
});
|
||||
|
||||
/* ── launch question ── */
|
||||
async function launchQuestion(questionId) {
|
||||
if (!activeSession) return;
|
||||
const q = allQuestions.find(x => x.id === questionId);
|
||||
if (!q) return;
|
||||
try {
|
||||
await LS.api('/api/live/' + activeSession.id + '/question', {
|
||||
const resp = await LS.api('/api/live/' + activeSession.id + '/question', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ question_id: questionId }),
|
||||
});
|
||||
currentQuestion = q;
|
||||
currentQuestion = resp.question || allQuestions.find(x => x.id === questionId) || { id: questionId };
|
||||
answerCount = 0;
|
||||
updateStudentCounter(0);
|
||||
renderActiveQuestion(q);
|
||||
renderActiveQuestion(currentQuestion);
|
||||
renderQuestionList();
|
||||
} catch (e) {
|
||||
LS.toast(e.message || 'Ошибка запуска вопроса', 'error');
|
||||
@@ -750,7 +862,7 @@
|
||||
const letter = String.fromCharCode(65 + idx);
|
||||
optionsHtml += `<div class="lq-active-opt${opt.is_correct ? ' correct' : ''}">
|
||||
<div class="lq-opt-letter">${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 data-text="${esc(opt.text)}"></span>
|
||||
</div>`;
|
||||
});
|
||||
optionsHtml += '</div>';
|
||||
@@ -762,7 +874,7 @@
|
||||
В эфире · <span class="badge ${diffCls}">${diffLabel}</span>
|
||||
${typeLabel ? `<span class="badge badge-type">${esc(typeLabel)}</span>` : ''}
|
||||
</div>
|
||||
<div class="lq-active-text">${esc(q.text)}</div>
|
||||
<div class="lq-active-text" data-text="${esc(q.text)}"></div>
|
||||
${optionsHtml}
|
||||
<div class="lq-counter">
|
||||
<div>
|
||||
@@ -779,6 +891,7 @@
|
||||
</button>
|
||||
<div id="results-area"></div>
|
||||
`;
|
||||
card.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
|
||||
lucide.createIcons();
|
||||
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
@@ -798,38 +911,48 @@
|
||||
}
|
||||
|
||||
function renderResults(data, container) {
|
||||
const { answers = [], options = [] } = data;
|
||||
// count answers per option
|
||||
const countMap = {};
|
||||
answers.forEach(a => {
|
||||
const key = a.option_id ?? a.answer ?? 'other';
|
||||
countMap[key] = (countMap[key] || 0) + 1;
|
||||
});
|
||||
const totalAnswers = answers.length;
|
||||
const maxCount = Math.max(...Object.values(countMap), 1);
|
||||
|
||||
// use options from current question if not in results
|
||||
const opts = options.length ? options : (currentQuestion?.options || []);
|
||||
const opts = data.options || [];
|
||||
const q = data.question || {};
|
||||
const stats = data.stats || {};
|
||||
const total = stats.total || 0;
|
||||
const correct= stats.correct|| 0;
|
||||
const maxCount = Math.max(...opts.map(o => o.chosen_count || 0), 1);
|
||||
|
||||
if (!opts.length) {
|
||||
container.innerHTML = '<div style="padding:16px;text-align:center;color:#8898AA;font-size:0.84rem">Нет данных о вариантах ответа</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const pctCorrect = total > 0 ? Math.round(correct / total * 100) : 0;
|
||||
const pctWrong = total > 0 ? 100 - pctCorrect : 0;
|
||||
|
||||
let html = `<div class="lq-results-wrap">
|
||||
<div class="lq-results-title"><i data-lucide="bar-chart-horizontal" style="width:14px;height:14px;opacity:0.5"></i> Результаты · ${totalAnswers} ответов</div>
|
||||
<div class="lq-results-title"><i data-lucide="bar-chart-horizontal" style="width:14px;height:14px;opacity:0.5"></i> Результаты</div>
|
||||
<div class="lq-result-stats">
|
||||
<div class="lq-result-stat">
|
||||
<div class="lq-result-stat-val">${total}</div>
|
||||
<div class="lq-result-stat-lbl">Ответов</div>
|
||||
</div>
|
||||
<div class="lq-result-stat rs-correct">
|
||||
<div class="lq-result-stat-val">${pctCorrect}%</div>
|
||||
<div class="lq-result-stat-lbl">Верно</div>
|
||||
</div>
|
||||
<div class="lq-result-stat rs-wrong">
|
||||
<div class="lq-result-stat-val">${pctWrong}%</div>
|
||||
<div class="lq-result-stat-lbl">Неверно</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-bars">`;
|
||||
|
||||
opts.forEach((opt, idx) => {
|
||||
const letter = String.fromCharCode(65 + idx);
|
||||
const key = opt.id ?? idx;
|
||||
const count = countMap[key] || 0;
|
||||
const pct = Math.round((count / Math.max(maxCount, 1)) * 100);
|
||||
const count = opt.chosen_count || 0;
|
||||
const pct = Math.round(count / maxCount * 100);
|
||||
const isCorrect = opt.is_correct;
|
||||
html += `<div class="result-bar-row">
|
||||
<div class="result-bar-label${isCorrect ? ' correct-lbl' : ''}">
|
||||
${isCorrect ? '<span class="rb-correct-marker"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>' : letter + '.'}
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100px">${esc(opt.text)}</span>
|
||||
<span class="rb-opt-text" data-text="${esc(opt.text)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100px"></span>
|
||||
</div>
|
||||
<div class="result-bar-track">
|
||||
<div class="result-bar-fill${isCorrect ? ' correct-fill' : ''}" style="width:${pct}%"></div>
|
||||
@@ -838,8 +961,16 @@
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div></div>';
|
||||
html += '</div>';
|
||||
if (q.explanation) {
|
||||
html += `<div class="lq-explanation">
|
||||
<div class="lq-explanation-label">Объяснение</div>
|
||||
<div class="lq-exp-text" data-text="${esc(q.explanation)}"></div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
container.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user