feat(classroom): открытие любого учебника в онлайн-уроке

Учитель может выбрать любой активный учебник из каталога /api/textbooks
и открыть его в общем iframe для всех участников. По аналогии с симуляциями:

- Backend: контроллер classroom/textbook.js + 4 роута
  (POST/DELETE /:id/textbook, /:id/textbook/nav, /:id/textbook/mode)
  с SSE-событиями classroom_textbook_open|close|nav|mode
- Embed-режим /textbook/:slug?embed=1: сервер injectит CSS+JS-bridge
  перед </head>, скрывая хедер/сайдбар и пересылая клики/скролл наверх
  через postMessage (без правки 40+ HTML-учебников)
- Frontend (classroom.html): кнопка «Учебник» в header, пикер с
  фильтрами по предмету, iframe-панель с режимами демо/свободно,
  relay nav-событий учителя → всем студентам в demo-режиме
This commit is contained in:
Maxim Dolgolyov
2026-05-29 11:41:57 +03:00
parent 21c5ae2d91
commit 068d6c2afe
5 changed files with 482 additions and 0 deletions
@@ -0,0 +1,80 @@
'use strict';
const db = require('../../db/db');
const { emitToSession } = require('./_shared');
const _stmtTextbookBySlug = db.prepare(
'SELECT slug, title, subject, grade FROM textbooks WHERE slug=? AND is_active=1'
);
function _requireTeacher(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) { res.status(404).json({ error: 'Сессия не активна' }); return null; }
if (session.teacher_id !== req.user.id && req.user.role !== 'admin') {
res.status(403).json({ error: 'Нет доступа' }); return null;
}
return sessionId;
}
function textbookOpen(req, res) {
const sessionId = _requireTeacher(req, res);
if (!sessionId) return;
const { slug, hash } = req.body || {};
if (!slug || typeof slug !== 'string' || !/^[a-z0-9_-]{1,80}$/i.test(slug))
return res.status(400).json({ error: 'Неверный slug' });
const tb = _stmtTextbookBySlug.get(slug);
if (!tb) return res.status(404).json({ error: 'Учебник не найден' });
const safeHash = (typeof hash === 'string' && /^[a-z0-9_-]{1,40}$/i.test(hash)) ? hash : null;
emitToSession(sessionId, {
type: 'classroom_textbook_open',
sessionId,
slug: tb.slug,
title: tb.title,
subject: tb.subject,
grade: tb.grade,
hash: safeHash,
});
res.json({ ok: true });
}
function textbookNav(req, res) {
const sessionId = _requireTeacher(req, res);
if (!sessionId) return;
const { slug, hash, scrollY } = req.body || {};
if (!slug || typeof slug !== 'string' || !/^[a-z0-9_-]{1,80}$/i.test(slug))
return res.status(400).json({ error: 'Неверный slug' });
if (!_stmtTextbookBySlug.get(slug))
return res.status(404).json({ error: 'Учебник не найден' });
const safeHash = (typeof hash === 'string' && /^[a-z0-9_-]{1,40}$/i.test(hash)) ? hash : null;
const sy = (typeof scrollY === 'number' && isFinite(scrollY)) ? Math.max(0, Math.min(scrollY, 1e6)) : null;
emitToSession(sessionId, {
type: 'classroom_textbook_nav',
sessionId, slug, hash: safeHash, scrollY: sy,
});
res.json({ ok: true });
}
function textbookMode(req, res) {
const sessionId = _requireTeacher(req, res);
if (!sessionId) return;
const { mode } = req.body || {};
if (mode !== 'demo' && mode !== 'free') return res.status(400).json({ error: 'mode must be demo|free' });
emitToSession(sessionId, { type: 'classroom_textbook_mode', sessionId, mode });
res.json({ ok: true });
}
function textbookClose(req, res) {
const sessionId = _requireTeacher(req, res);
if (!sessionId) return;
emitToSession(sessionId, { type: 'classroom_textbook_close', sessionId });
res.json({ ok: true });
}
module.exports = { textbookOpen, textbookNav, textbookMode, textbookClose };
@@ -8,5 +8,6 @@ module.exports = {
...require('./classroom/chat'),
...require('./classroom/permissions'),
...require('./classroom/sim'),
...require('./classroom/textbook'),
...require('./classroom/admin'),
};
+6
View File
@@ -108,6 +108,12 @@ router.post('/:id/sim/state', ...teacher, c.simState);
router.post('/:id/sim/mode', ...teacher, c.simMode);
router.post('/:id/sim/annotate', ...teacher, c.simAnnotate);
// Textbook: open/close/navigate for all participants
router.post('/:id/textbook', ...teacher, c.textbookOpen);
router.delete('/:id/textbook', ...teacher, c.textbookClose);
router.post('/:id/textbook/nav', ...teacher, c.textbookNav);
router.post('/:id/textbook/mode', ...teacher, c.textbookMode);
// Cursor broadcast (all participants)
router.post('/:id/cursor', ...auth, cursorLimiter, c.broadcastCursor);
+93
View File
@@ -326,13 +326,106 @@ app.use((req, res, next) => {
});
// Clean URL for textbooks: /textbook/<slug> → frontend/textbooks/<html_path>
// With ?embed=1 — inject CSS that hides headers/sidebars + JS-bridge for classroom sync.
const fs = require('fs');
const _textbookDb = require('./db/db');
const _stmtTextbookPath = _textbookDb.prepare('SELECT html_path FROM textbooks WHERE slug=? AND is_active=1');
const _embedCache = new Map(); // html_path → {mtime, html}
const EMBED_INJECT = `
<style id="__ls_embed_style__">
/* Скрываем хедеры/боковые навигации в embed-режиме classroom */
.hdr, .hdr-back, .hdr-side, .app-layout > .sidebar, .sidebar, .mob-bar,
.topbar, .topbar-nav, .tb-back, .ls-topbar, .ls-sidebar, .sb-content > .sidebar,
header.hdr, nav.sidebar { display: none !important; }
.app-layout { display: block !important; }
.sb-content { margin-left: 0 !important; width: 100% !important; }
body { padding-top: 0 !important; margin-top: 0 !important; }
main { padding-top: 16px !important; }
html, body { background: #fff; }
</style>
<script id="__ls_embed_bridge__">
(function(){
try {
if (window.parent === window) return;
var SLUG = __LS_SLUG__;
// Перехват внутренних ссылок → передача наверх (учитель транслирует всем)
document.addEventListener('click', function(e){
var a = e.target.closest && e.target.closest('a[href]');
if (!a) return;
var href = a.getAttribute('href') || '';
if (!href || href.startsWith('javascript:')) return;
if (href.startsWith('#')) {
// hash-навигация внутри страницы
window.parent.postMessage({ type:'ls_tb_nav', slug:SLUG, hash:href.slice(1) }, '*');
return;
}
// ссылка на другой учебник (/textbook/<slug>)
var m = href.match(/^\\/textbook\\/([a-z0-9_-]+)/i);
if (m) {
e.preventDefault();
window.parent.postMessage({ type:'ls_tb_nav', slug:m[1], hash:null }, '*');
}
}, true);
// Скролл (throttled) — учитель транслирует
var st;
window.addEventListener('scroll', function(){
clearTimeout(st);
st = setTimeout(function(){
window.parent.postMessage({ type:'ls_tb_scroll', slug:SLUG, scrollY:window.scrollY }, '*');
}, 250);
}, { passive:true });
// Принимаем команды от parent (для студентов в demo-режиме)
window.addEventListener('message', function(e){
var d = e.data; if (!d || typeof d !== 'object') return;
if (d.type === 'ls_tb_apply') {
if (d.hash != null) { try { location.hash = '#' + d.hash; } catch(_){} }
if (typeof d.scrollY === 'number') { try { window.scrollTo(0, d.scrollY); } catch(_){} }
}
if (d.type === 'ls_tb_lock') {
// блокируем взаимодействие у студентов в demo
var el = document.getElementById('__ls_embed_lock__');
if (d.locked && !el) {
el = document.createElement('div');
el.id = '__ls_embed_lock__';
el.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:transparent;cursor:not-allowed';
document.body.appendChild(el);
} else if (!d.locked && el) {
el.remove();
}
}
});
// Сообщаем parent что embed готов
window.parent.postMessage({ type:'ls_tb_ready', slug:SLUG }, '*');
} catch(_){}
})();
</script>
`;
function _renderEmbed(filePath, slug) {
let stat; try { stat = fs.statSync(filePath); } catch { return null; }
const cached = _embedCache.get(filePath);
if (cached && cached.mtime === stat.mtimeMs && cached.slug === slug) return cached.html;
let html;
try { html = fs.readFileSync(filePath, 'utf8'); } catch { return null; }
const inject = EMBED_INJECT.replace('__LS_SLUG__', JSON.stringify(slug));
if (html.includes('</head>')) html = html.replace('</head>', inject + '</head>');
else html = inject + html;
_embedCache.set(filePath, { mtime: stat.mtimeMs, slug, html });
return html;
}
app.get('/textbook/:slug', (req, res, next) => {
const row = _stmtTextbookPath.get(req.params.slug);
if (!row) return next();
const filePath = path.join(frontendDir, 'textbooks', row.html_path);
if (!isProd) res.setHeader('Cache-Control', 'no-store');
if (req.query.embed === '1') {
const html = _renderEmbed(filePath, req.params.slug);
if (html == null) return next();
res.setHeader('Content-Type', 'text/html; charset=utf-8');
return res.send(html);
}
res.sendFile(filePath, err => { if (err) next(); });
});
+302
View File
@@ -2118,6 +2118,52 @@
.cr-sim-picker-card-cat.chem { background: rgba(241,91,181,0.1); border-color: rgba(241,91,181,0.3); color: #F15BB5; }
.cr-sim-picker-card-cat.bio { background: rgba(168,224,99,0.1); border-color: rgba(168,224,99,0.3); color: #A8E063; }
.cr-sim-picker-card-cat.game { background: rgba(255,159,67,0.1); border-color: rgba(255,159,67,0.3); color: #FF9F43; }
/* ── Textbook panel (mirrors sim panel) ─────────────────────────────── */
.cr-tb-panel {
position: absolute; inset: 0; z-index: 5;
background: #fff; display: none; flex-direction: column;
border-left: 1px solid rgba(155,93,229,0.2);
}
.cr-tb-panel.open { display: flex; }
.cr-tb-bar {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px; background: #1a0f2e; flex-shrink: 0;
border-bottom: 1px solid rgba(155,93,229,0.2);
}
.cr-tb-bar-icon {
width: 24px; height: 24px; border-radius: 7px;
background: rgba(6,214,224,0.15); border: 1px solid rgba(6,214,224,0.3);
display: flex; align-items: center; justify-content: center;
}
.cr-tb-bar-icon svg { width: 12px; height: 12px; stroke: #06D6E0; }
.cr-tb-bar-title { font-size: 0.82rem; font-weight: 700; color: #f0e8ff; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-tb-bar-close {
width: 26px; height: 26px; border-radius: 7px; border: none;
background: rgba(241,91,181,0.1); color: rgba(255,255,255,0.7);
cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s, color .15s;
}
.cr-tb-bar-close:hover { background: rgba(241,91,181,0.18); color: #F15BB5; }
.cr-tb-bar-close svg { width: 14px; height: 14px; }
.cr-tb-mode {
display: flex; gap: 4px; padding: 3px; background: rgba(255,255,255,0.04);
border-radius: 99px; border: 1px solid rgba(255,255,255,0.06);
}
.cr-tb-mode-btn {
padding: 4px 12px; border-radius: 99px; border: 1.5px solid transparent;
background: transparent; color: rgba(255,255,255,0.5);
font-family: 'Manrope',sans-serif; font-size: 0.72rem; font-weight: 700; cursor: pointer; transition: all .15s;
}
.cr-tb-mode-btn:hover { color: rgba(255,255,255,0.7); }
.cr-tb-mode-btn.active { background: rgba(6,214,224,0.18); border-color: rgba(6,214,224,0.45); color: #06D6E0; }
.cr-tb-frame {
flex: 1; width: 100%; border: 0; background: #fff;
}
.cr-tb-blocker {
position: absolute; inset: 38px 0 0 0;
display: none; background: transparent; cursor: not-allowed; z-index: 10;
}
.cr-tb-blocker.active { display: block; }
</style>
</head>
<body>
@@ -2149,6 +2195,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>
<span>Симуляция</span>
</button>
<button class="cr-header-btn" id="cr-tb-btn" onclick="crOpenTbPicker()" style="display:none" title="Открыть учебник">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
<span>Учебник</span>
</button>
<!-- hand raise: student only -->
<button class="cr-hand-btn" id="cr-hand-btn" onclick="crToggleHand()" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>
@@ -2269,6 +2319,24 @@
<!-- Blocks student interaction in demo mode -->
<div class="cr-sim-blocker" id="cr-sim-blocker"></div>
</div>
<!-- Textbook iframe overlay (shown when teacher opens a textbook) -->
<div class="cr-tb-panel" id="cr-tb-panel">
<div class="cr-tb-bar">
<div class="cr-tb-bar-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
</div>
<span class="cr-tb-bar-title" id="cr-tb-bar-title">Учебник</span>
<div class="cr-tb-mode" id="cr-tb-mode-toggle" style="display:none">
<button class="cr-tb-mode-btn active" id="cr-tb-mode-demo" onclick="crSetTbMode('demo')" title="Все видят то же, что и учитель">Демо</button>
<button class="cr-tb-mode-btn" id="cr-tb-mode-free" onclick="crSetTbMode('free')" title="Каждый листает самостоятельно">Свободно</button>
</div>
<button class="cr-tb-bar-close" id="cr-tb-bar-close" onclick="crTeacherCloseTb()" title="Закрыть учебник" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<iframe class="cr-tb-frame" id="cr-tb-frame" src="about:blank"></iframe>
<div class="cr-tb-blocker" id="cr-tb-blocker"></div>
</div>
<!-- Annotate-mode floating bar (shown on top of sim when drawing) -->
<div class="cr-annotate-bar" id="cr-annotate-bar">
<span class="cr-annotate-bar-label">
@@ -3678,6 +3746,14 @@
if (_sessionId == data.sessionId) onSimModeChange(data.mode);
} else if (data.type === 'classroom_sim_annotate') {
if (_sessionId == data.sessionId) _crApplyAnnotate(data.active);
} else if (data.type === 'classroom_textbook_open') {
if (_sessionId == data.sessionId) onTbOpen(data);
} else if (data.type === 'classroom_textbook_close') {
if (_sessionId == data.sessionId) onTbClose();
} else if (data.type === 'classroom_textbook_nav') {
if (_sessionId == data.sessionId) onTbNav(data);
} else if (data.type === 'classroom_textbook_mode') {
if (_sessionId == data.sessionId) onTbModeChange(data.mode);
} else if (data.type === '_sse_reconnect') {
// SSE reconnected after a drop — re-sync all real-time state to fill the gap
if (_sessionId) resyncAfterReconnect();
@@ -3980,6 +4056,7 @@
document.getElementById('cr-leave-btn').style.display = isTeacher ? 'none' : 'flex';
document.getElementById('cr-screen-btn').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-sim-btn').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-tb-btn').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-guest-btn').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-share-lib-btn').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-hand-btn').style.display = isTeacher ? 'none' : 'flex';
@@ -7099,6 +7176,211 @@
}, 300);
});
/* ════════════════════════════════════════════════════════════════════
TEXTBOOK in classroom — open any textbook in shared iframe
════════════════════════════════════════════════════════════════════ */
let _tbActive = null; // current slug, or null
let _tbMode = 'demo'; // 'demo' | 'free'
let _tbList = null; // cached catalog from GET /api/textbooks
let _tbPickerSubj = 'all';
let _tbNavThrottle = null;
async function crOpenTbPicker() {
if (_tbActive) { crTeacherCloseTb(); return; }
const overlay = document.getElementById('cr-tb-picker-overlay');
overlay.classList.add('open');
if (!_tbList) {
try {
const data = await LS.get('/api/textbooks');
_tbList = Array.isArray(data) ? data : (data?.textbooks || data?.items || []);
} catch (e) {
document.getElementById('cr-tb-picker-grid').innerHTML =
`<div style="grid-column:1/-1;color:#F15BB5;padding:24px;text-align:center;font-size:.85rem">Не удалось загрузить список учебников</div>`;
return;
}
_crRenderTbCats();
}
_crRenderTbGrid(_tbPickerSubj);
}
function crCloseTbPicker() {
document.getElementById('cr-tb-picker-overlay').classList.remove('open');
}
function crFilterTbs(subj, btn) {
_tbPickerSubj = subj;
document.querySelectorAll('#cr-tb-picker-cats .cr-sim-cat-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_crRenderTbGrid(subj);
}
function _crRenderTbCats() {
const subjs = [...new Set((_tbList || []).map(t => t.subject).filter(Boolean))].sort();
const SUBJ_LABEL = {
math: 'Математика', algebra: 'Алгебра', geometry: 'Геометрия',
physics: 'Физика', chemistry: 'Химия', biology: 'Биология',
informatics: 'Информатика', russian: 'Русский', english: 'Английский',
};
const cats = document.getElementById('cr-tb-picker-cats');
cats.innerHTML = `<button class="cr-sim-cat-btn active" onclick="crFilterTbs('all', this)">Все</button>` +
subjs.map(s => `<button class="cr-sim-cat-btn" onclick="crFilterTbs('${s}', this)">${SUBJ_LABEL[s] || s}</button>`).join('');
}
function _crRenderTbGrid(subj) {
const grid = document.getElementById('cr-tb-picker-grid');
const list = (_tbList || []).filter(t => subj === 'all' || t.subject === subj);
if (!list.length) {
grid.innerHTML = `<div style="grid-column:1/-1;color:rgba(255,255,255,0.5);padding:24px;text-align:center;font-size:.85rem">Нет учебников</div>`;
return;
}
grid.innerHTML = list.map(t => {
const slugSafe = String(t.slug).replace(/[^a-z0-9_-]/gi, '');
const titleEsc = String(t.title || t.slug).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const grade = t.grade ? `${t.grade} кл.` : '';
return `
<div class="cr-sim-picker-card" onclick="crPickTb('${slugSafe}')" title="${titleEsc}">
<span class="cr-sim-picker-card-cat math">${grade || (t.subject || '')}</span>
<span class="cr-sim-picker-card-title">${titleEsc}</span>
</div>`;
}).join('');
}
async function crPickTb(slug) {
crCloseTbPicker();
if (!_sessionId) return;
try {
await LS.post(`/api/classroom/${_sessionId}/textbook`, { slug });
// onTbOpen will fire via SSE echo
} catch (e) {
LS.toast(e.message || 'Ошибка открытия учебника', 'error');
}
}
async function crTeacherCloseTb() {
if (!_sessionId) return;
try { await LS.del(`/api/classroom/${_sessionId}/textbook`); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function crSetTbMode(mode) {
if (!_sessionId) return;
try { await LS.post(`/api/classroom/${_sessionId}/textbook/mode`, { mode }); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function onTbOpen(data) {
_tbActive = data.slug;
const panel = document.getElementById('cr-tb-panel');
const frame = document.getElementById('cr-tb-frame');
const titleEl = document.getElementById('cr-tb-bar-title');
const closeBtn = document.getElementById('cr-tb-bar-close');
const modeTog = document.getElementById('cr-tb-mode-toggle');
const headerBtn = document.getElementById('cr-tb-btn');
const blocker = document.getElementById('cr-tb-blocker');
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
titleEl.textContent = data.title || data.slug;
let src = `/textbook/${encodeURIComponent(data.slug)}?embed=1`;
if (data.hash) src += '#' + data.hash;
frame.src = src;
panel.classList.add('open');
if (closeBtn) closeBtn.style.display = isTeacher ? 'flex' : 'none';
if (modeTog) modeTog.style.display = isTeacher ? 'flex' : 'none';
if (headerBtn && isTeacher) {
headerBtn.title = 'Закрыть учебник';
headerBtn.querySelector('span').textContent = 'Закрыть';
}
if (blocker) blocker.classList.toggle('active', !isTeacher && _tbMode === 'demo');
onTbModeChange(_tbMode);
}
function onTbClose() {
_tbActive = null;
const panel = document.getElementById('cr-tb-panel');
const frame = document.getElementById('cr-tb-frame');
const headerBtn = document.getElementById('cr-tb-btn');
const modeTog = document.getElementById('cr-tb-mode-toggle');
const blocker = document.getElementById('cr-tb-blocker');
panel.classList.remove('open');
if (modeTog) modeTog.style.display = 'none';
if (blocker) blocker.classList.remove('active');
setTimeout(() => { if (frame) frame.src = 'about:blank'; }, 300);
if (headerBtn) {
headerBtn.title = 'Открыть учебник';
const sp = headerBtn.querySelector('span'); if (sp) sp.textContent = 'Учебник';
}
}
function onTbNav(data) {
if (!_tbActive) return;
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
// In demo mode студенты следуют за учителем. В free — игнорируем nav-события.
if (isTeacher) return;
if (_tbMode !== 'demo') return;
const frame = document.getElementById('cr-tb-frame');
if (!frame) return;
// Если slug сменился — перезагружаем iframe, иначе шлём postMessage
if (data.slug && data.slug !== _tbActive) {
_tbActive = data.slug;
document.getElementById('cr-tb-bar-title').textContent = data.title || data.slug;
let src = `/textbook/${encodeURIComponent(data.slug)}?embed=1`;
if (data.hash) src += '#' + data.hash;
frame.src = src;
} else {
frame.contentWindow?.postMessage(
{ type: 'ls_tb_apply', hash: data.hash, scrollY: data.scrollY }, '*'
);
}
}
function onTbModeChange(mode) {
_tbMode = mode;
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
const blocker = document.getElementById('cr-tb-blocker');
const frame = document.getElementById('cr-tb-frame');
if (blocker) blocker.classList.toggle('active', !isTeacher && mode === 'demo');
document.getElementById('cr-tb-mode-demo')?.classList.toggle('active', mode === 'demo');
document.getElementById('cr-tb-mode-free')?.classList.toggle('active', mode === 'free');
// Сообщаем iframe — блокировать клики у студента в demo
if (frame && !isTeacher) {
frame.contentWindow?.postMessage({ type: 'ls_tb_lock', locked: mode === 'demo' }, '*');
}
}
// Teacher: relay textbook nav (hash/scroll) from iframe → backend → SSE
window.addEventListener('message', e => {
if (!_tbActive || !_sessionId) return;
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
if (!isTeacher) return;
const d = e.data;
if (!d || typeof d !== 'object') return;
if (d.type === 'ls_tb_nav') {
// navigation: hash или ссылка на другой учебник — отправляем сразу
const slug = d.slug || _tbActive;
LS.post(`/api/classroom/${_sessionId}/textbook/nav`, {
slug, hash: d.hash || null
}).catch(() => {});
// если открыли другой учебник — учитель сам переключает iframe (студенты получат через SSE)
if (slug !== _tbActive) {
const frame = document.getElementById('cr-tb-frame');
if (frame) {
let src = `/textbook/${encodeURIComponent(slug)}?embed=1`;
if (d.hash) src += '#' + d.hash;
frame.src = src;
_tbActive = slug;
}
}
} else if (d.type === 'ls_tb_scroll') {
clearTimeout(_tbNavThrottle);
_tbNavThrottle = setTimeout(() => {
LS.post(`/api/classroom/${_sessionId}/textbook/nav`, {
slug: _tbActive, scrollY: d.scrollY
}).catch(() => {});
}, 350);
}
});
function crToggleDrawPermission(uid) {
if (!_sessionId) return;
const numUid = Number(uid);
@@ -7841,6 +8123,26 @@
</div><!-- /.cr-settings-panel -->
</div><!-- /.cr-settings-overlay -->
<!-- ── Textbook picker modal (re-uses .cr-sim-picker-* styles) ─────────── -->
<div class="cr-sim-picker-overlay" id="cr-tb-picker-overlay" onclick="if(event.target===this)crCloseTbPicker()">
<div class="cr-sim-picker-modal">
<div class="cr-sim-picker-head">
<h3>Выбрать учебник</h3>
<button class="cr-sim-picker-close" onclick="crCloseTbPicker()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="cr-sim-picker-cats" id="cr-tb-picker-cats">
<button class="cr-sim-cat-btn active" onclick="crFilterTbs('all', this)">Все</button>
</div>
<div class="cr-sim-picker-body">
<div class="cr-sim-picker-grid" id="cr-tb-picker-grid">
<div style="grid-column:1/-1;color:rgba(255,255,255,0.5);padding:24px;text-align:center;font-size:.85rem">Загрузка…</div>
</div>
</div>
</div>
</div>
<!-- ── Simulation picker modal ─────────────────────────────────────────── -->
<div class="cr-sim-picker-overlay" id="cr-sim-picker-overlay" onclick="if(event.target===this)crCloseSimPicker()">
<div class="cr-sim-picker-modal">