Files
Learn_System/frontend/js/textbook-tracker.js
T
Maxim Dolgolyov dd7daa7d7a fix(tracker): 4-й хук — polling по .para-pill.active
Если ни bubble, ни capture, ни setParaTab-patch не сработали (например,
страница использует другой механизм навигации), наблюдаем DOM раз в
500мс на изменение класса .active у пилюли. Когда активная пилюля
меняется — фиксируем визит.

Это самый robust способ: работает независимо от событий, функций и
библиотек страницы. Стоит копейки — один querySelector в 500мс.
2026-05-27 17:47:33 +03:00

559 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ──────────────────────────────────────────────────────────────────
textbook-tracker.js — injected into each textbook page.
- "Back to LearnSpace" button overlay (top-left)
- localStorage progress tracking (always works, even logged out)
- Server-side sync when authenticated (via LS.api)
- Per-paragraph "Прочитано" checkbox UI
────────────────────────────────────────────────────────────────── */
(function () {
const slug = (function () {
const m = location.pathname.match(/\/textbook\/([\w-]+)/);
if (m) return m[1];
// Fallback for direct file access during dev
const fname = location.pathname.split('/').pop().replace(/\.html$/, '');
// Normalise physics8_* → physics-8-* (e.g. physics8_thermal → physics-8-thermal)
const norm = fname.replace(/^physics8_/, 'physics-8-');
return norm.replace(/_/g, '-');
})();
const lsKey = 'textbook_progress_' + slug;
const localState = (function () {
try { return JSON.parse(localStorage.getItem(lsKey) || '{"read":[],"last":null}'); }
catch { return { read: [], last: null }; }
})();
if (!Array.isArray(localState.read)) localState.read = [];
/* ── 1. Server sync (best-effort, с очередью) ─────────────────────
Если POST уже в полёте — следующий патч копится в pendingExtra
и отправляется после завершения. Так ни один mark_read не
теряется при быстрых кликах. */
let syncPending = false;
let pendingExtra = null;
function syncToServer(extra) {
if (typeof LS === 'undefined' || !LS.getToken) { console.warn('[tracker] LS не загружен — пропускаем sync'); return; }
if (!LS.getToken()) { console.warn('[tracker] нет токена в localStorage — пользователь не залогинен'); return; }
if (syncPending) {
pendingExtra = Object.assign(pendingExtra || {}, extra || {});
return;
}
syncPending = true;
const body = JSON.stringify({ last_para: localState.last, ...(extra || {}) });
console.log('[tracker]', slug, '→ POST', body);
fetch('/api/textbooks/' + slug + '/progress', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + LS.getToken(),
},
body,
}).then(r => {
console.log('[tracker]', slug, '← HTTP', r.status);
if (!r.ok) r.text().then(t => console.warn('[tracker] ошибка:', t));
}).finally(() => {
syncPending = false;
if (pendingExtra) {
const next = pendingExtra; pendingExtra = null;
syncToServer(next);
}
}).catch(e => console.warn('[tracker] fetch упал:', e));
}
/* ── 2. Initial load: merge server data into local state + push back ──
Если в локальном кэше есть ключи, которых нет на сервере
(последствие старого бага syncPending) — досылаем их через
отдельные mark_read POST'ы. Это лечит «вечный 0/N» у пользователей,
которые до фикса уже накликали кучу пилюль. */
function loadServerProgress() {
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
fetch('/api/textbooks/' + slug, {
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
})
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d || !d.progress) return;
const serverRead = new Set(d.progress.read || []);
const localRead = localState.read || [];
const missing = localRead.filter(k => !serverRead.has(k));
// объединяем для UI
const merged = Array.from(new Set([...localRead, ...d.progress.read || []]));
localState.read = merged;
if (!localState.last) localState.last = d.progress.last_para;
localStorage.setItem(lsKey, JSON.stringify(localState));
refreshAllUI();
// догоняем сервер последовательно: syncToServer уже коалесцирует
missing.forEach(k => syncToServer({ mark_read: k }));
})
.catch(() => {});
}
/* ── 3. Save helpers ──────────────────────────────────────────── */
function persist() { try { localStorage.setItem(lsKey, JSON.stringify(localState)); } catch {} }
function setLastPara(key) {
if (!key) return;
localState.last = key;
persist();
syncToServer({});
}
function markRead(key) {
if (!key || localState.read.includes(key)) return;
localState.read.push(key);
persist();
refreshPillUI(key);
refreshCheckUI(key);
syncToServer({ mark_read: key });
}
function unmarkRead(key) {
if (!key) return;
const i = localState.read.indexOf(key);
if (i < 0) return;
localState.read.splice(i, 1);
persist();
refreshPillUI(key);
refreshCheckUI(key);
syncToServer({ mark_unread: key });
}
function toggleRead(key) {
if (localState.read.includes(key)) unmarkRead(key);
else markRead(key);
}
/* ── 4. UI: back button overlay ───────────────────────────────── */
function installBackButton() {
if (document.getElementById('tb-back-btn')) return;
const btn = document.createElement('a');
btn.id = 'tb-back-btn';
btn.href = '/textbooks';
btn.title = 'К каталогу учебников';
btn.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
<span>Учебники</span>`;
Object.assign(btn.style, {
position: 'fixed', top: '10px', left: '12px', zIndex: '9999',
display: 'inline-flex', alignItems: 'center', gap: '6px',
padding: '6px 11px 6px 9px', borderRadius: '20px',
background: 'rgba(0,0,0,.45)', color: '#fff',
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '12.5px', fontWeight: '700',
textDecoration: 'none', backdropFilter: 'blur(6px)',
transition: 'background .15s, transform .12s',
boxShadow: '0 2px 8px rgba(0,0,0,.18)',
});
btn.querySelector('svg').style.cssText = 'width:14px;height:14px;flex-shrink:0';
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.7)'; btn.style.transform = 'translateY(-1px)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(0,0,0,.45)'; btn.style.transform = 'none'; });
document.body.appendChild(btn);
}
/* ── 5. UI: mark-read checkboxes near every paragraph heading ─ */
function installReadCheckboxes() {
// Each para has a wrapper with id='p1','p2' etc. (data-para usage)
document.querySelectorAll('[data-para]').forEach(el => {
// Skip pill buttons (they only navigate); only target paragraph content blocks
if (el.classList.contains('para-pill')) return;
injectCheckIntoSection(el);
});
// Also look for sections by id matching pN pattern in case data-para isn't on the section
document.querySelectorAll('section[id^="p"], div[id^="p"]').forEach(el => {
if (/^p\d+$/.test(el.id) && !el.querySelector(':scope > .tb-readchk')) {
injectCheckIntoSection(el);
}
});
}
function injectCheckIntoSection(sectionEl) {
const key = sectionEl.dataset.para || sectionEl.id;
if (!key || !/^p\d+/.test(key)) return;
if (sectionEl.querySelector(':scope > .tb-readchk')) return;
// Find the first heading inside the section to insert next to it
const heading = sectionEl.querySelector('h1, h2, h3');
if (!heading) return;
const wrap = document.createElement('button');
wrap.className = 'tb-readchk';
wrap.dataset.para = key;
wrap.type = 'button';
wrap.title = 'Отметить как прочитанное';
wrap.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <span>Прочитано</span>`;
Object.assign(wrap.style, {
marginLeft: '12px',
display: 'inline-flex', alignItems: 'center', gap: '5px',
padding: '4px 10px', borderRadius: '99px',
border: '1.5px solid currentColor', background: 'transparent',
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '11.5px', fontWeight: '700',
cursor: 'pointer', verticalAlign: 'middle', opacity: '.55',
transition: 'opacity .15s, background .15s, color .15s',
});
wrap.querySelector('svg').style.cssText = 'width:12px;height:12px;flex-shrink:0';
wrap.addEventListener('mouseenter', () => { wrap.style.opacity = '1'; });
wrap.addEventListener('mouseleave', () => {
if (!localState.read.includes(key)) wrap.style.opacity = '.55';
});
wrap.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
toggleRead(key);
});
heading.appendChild(wrap);
refreshCheckUI(key);
}
/* ── 6. UI refreshers ─────────────────────────────────────────── */
function refreshPillUI(key) {
document.querySelectorAll(`.para-pill[data-para="${key}"]`).forEach(p => {
p.classList.toggle('tb-read', localState.read.includes(key));
});
}
function refreshCheckUI(key) {
document.querySelectorAll(`.tb-readchk[data-para="${key}"]`).forEach(b => {
const isRead = localState.read.includes(key);
b.style.background = isRead ? 'rgba(16,185,129,.15)' : 'transparent';
b.style.color = isRead ? '#059669' : '';
b.style.opacity = isRead ? '1' : '.55';
b.querySelector('span').textContent = isRead ? 'Прочитано' : 'Прочитано';
});
}
function refreshAllUI() {
localState.read.forEach(k => { refreshPillUI(k); refreshCheckUI(k); });
}
/* ── 7. Pill click → ВСЕГДА шлём last_para + mark_read одним POST
Идемпотентно: на сервере `if (!arr.includes(mark_read)) arr.push(...)`
не добавит дубликат. Эта «избыточность» лечит самовосстановлением
ситуации, когда localState.read был засорён старым syncPending-багом
(есть ключ локально, нет на сервере, mark_read иначе никогда не уйдёт). */
function recordParaVisit(key) {
if (!key) return;
localState.last = key;
if (!localState.read.includes(key)) {
localState.read.push(key);
}
refreshPillUI(key);
refreshCheckUI(key);
persist();
syncToServer({ mark_read: key });
}
function wirePillTracking() {
// Hook 1: всплытие click до body. Работает в обычном HTML.
document.body.addEventListener('click', e => {
const pill = e.target && e.target.closest && e.target.closest('.para-pill[data-para]');
if (!pill) return;
console.log('[tracker] клик по пилюле (bubble)', pill.dataset.para);
recordParaVisit(pill.dataset.para);
});
// Hook 2: capture-фаза (ловит до того, как кто-то остановит propagation).
document.addEventListener('click', e => {
const pill = e.target && e.target.closest && e.target.closest('.para-pill[data-para]');
if (!pill) return;
// Защита от двойного срабатывания — отметим pill сразу.
if (pill.__tbVisited) return;
pill.__tbVisited = true;
setTimeout(() => { pill.__tbVisited = false; }, 100);
console.log('[tracker] клик по пилюле (capture)', pill.dataset.para);
recordParaVisit(pill.dataset.para);
}, true);
// Hook 3: monkey-patch setParaTab — кликом по пилюле химия/физика 9 вызывают
// inline onclick="setParaTab('pN')". Перехват напрямую = работает даже если
// event-bubbling сломан расширением браузера или CSS overlay.
function patchSetParaTab() {
if (typeof window.setParaTab !== 'function' || window.setParaTab.__tbPatched) return;
const orig = window.setParaTab;
const wrapped = function (para) {
try {
if (para && /^p\d+$/i.test(String(para))) {
console.log('[tracker] setParaTab перехвачен', para);
recordParaVisit(String(para));
}
} catch (e) { console.warn('[tracker] patch error:', e); }
return orig.apply(this, arguments);
};
wrapped.__tbPatched = true;
window.setParaTab = wrapped;
console.log('[tracker] setParaTab успешно обёрнут');
}
patchSetParaTab();
// Если страница определяет setParaTab позже — поймаем через короткие опросы.
let tries = 0;
const ivl = setInterval(() => {
patchSetParaTab();
if (typeof window.setParaTab === 'function' && window.setParaTab.__tbPatched) clearInterval(ivl);
if (++tries > 20) clearInterval(ivl);
}, 100);
// Hook 4: polling — наблюдаем за классом .active на пилюлях.
// Если кто-то поменял активный параграф (через клик, через JS вызов
// setParaTab, через любой механизм) — мы это поймаем за 500мс и зафиксируем.
// Самый robust способ; не зависит ни от событий, ни от наличия функций.
let lastActivePara = null;
setInterval(() => {
const active = document.querySelector('.para-pill.active[data-para]');
if (!active) return;
const para = active.dataset.para;
if (para && para !== lastActivePara) {
if (lastActivePara !== null) console.log('[tracker] активный параграф изменился на', para);
lastActivePara = para;
recordParaVisit(para);
}
}, 500);
}
/* ── 8. Inject styling for read-pills (subtle green dot) ─────── */
function injectStyles() {
const s = document.createElement('style');
s.textContent = `
.para-pill.tb-read { position: relative; }
.para-pill.tb-read::after {
content: ''; position: absolute; top: 3px; right: 3px;
width: 6px; height: 6px; border-radius: 50%;
background: #10b981; box-shadow: 0 0 0 1.5px rgba(255,255,255,.9);
}
@media (max-width: 600px) {
#tb-back-btn span { display: none; }
#tb-back-btn { padding: 7px 8px; }
}
`;
document.head.appendChild(s);
}
/* ── 9a. Bookmarks (highlights/notes) ─────────────────────────── */
let bookmarks = [];
function loadBookmarks() {
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return Promise.resolve();
return fetch('/api/textbooks/' + slug + '/bookmarks', {
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
})
.then(r => r.ok ? r.json() : { bookmarks: [] })
.then(d => { bookmarks = d.bookmarks || []; })
.catch(() => {});
}
function escHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function installBookmarksBtn() {
if (document.getElementById('tb-bm-btn')) return;
const btn = document.createElement('button');
btn.id = 'tb-bm-btn';
btn.title = 'Мои закладки';
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>`;
Object.assign(btn.style, {
position: 'fixed', top: '10px', left: '125px', zIndex: '9999',
width: '34px', height: '32px', border: 'none', borderRadius: '20px',
background: 'rgba(0,0,0,.45)', color: '#fff',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', backdropFilter: 'blur(6px)',
transition: 'background .15s, transform .12s',
boxShadow: '0 2px 8px rgba(0,0,0,.18)',
});
btn.querySelector('svg').style.cssText = 'width:14px;height:14px';
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.7)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(0,0,0,.45)'; });
btn.addEventListener('click', toggleBookmarksPanel);
document.body.appendChild(btn);
}
function installBookmarksPanel() {
if (document.getElementById('tb-bm-panel')) return;
const panel = document.createElement('div');
panel.id = 'tb-bm-panel';
Object.assign(panel.style, {
position: 'fixed', top: '50px', left: '12px', zIndex: '9998',
width: '320px', maxHeight: '60vh', overflowY: 'auto',
background: '#fff', color: '#1c1917',
border: '1px solid rgba(0,0,0,.12)', borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,.25)',
padding: '12px', display: 'none',
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '13px',
});
document.body.appendChild(panel);
}
function toggleBookmarksPanel() {
const panel = document.getElementById('tb-bm-panel');
if (panel.style.display === 'block') { panel.style.display = 'none'; return; }
renderBookmarksPanel();
panel.style.display = 'block';
}
function renderBookmarksPanel() {
const panel = document.getElementById('tb-bm-panel');
if (!panel) return;
if (!bookmarks.length) {
panel.innerHTML = `<div style="padding:14px;text-align:center;color:#78716c">
Закладок нет<br><small>Выдели любой текст в учебнике и нажми «+ Закладка»</small>
</div>`;
return;
}
panel.innerHTML = `
<div style="font-weight:800;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#78716c;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid rgba(0,0,0,.08)">
Мои закладки (${bookmarks.length})
</div>
${bookmarks.map(b => {
const colorMap = { yellow:'#fef08a', green:'#bbf7d0', blue:'#bfdbfe', pink:'#fbcfe8' };
const borderMap= { yellow:'#ca8a04', green:'#16a34a', blue:'#2563eb', pink:'#db2777' };
const bg = colorMap[b.color] || colorMap.yellow;
const bd = borderMap[b.color] || borderMap.yellow;
const paraLink = b.para ? `<a href="#${b.para}" style="text-decoration:none;color:${bd};font-weight:700;margin-right:6px">§${b.para.replace('p','')}</a>` : '';
return `<div style="background:${bg};border-left:3px solid ${bd};padding:8px 10px;border-radius:6px;margin-bottom:7px;position:relative">
${paraLink}<span style="font-size:12.5px">«${escHtml(b.text)}»</span>
${b.note ? `<div style="margin-top:5px;color:#44403c;font-style:italic;font-size:12px">${escHtml(b.note)}</div>` : ''}
<button onclick="window.__tbDeleteBookmark(${b.id})" style="position:absolute;top:5px;right:5px;border:none;background:none;cursor:pointer;color:#78716c;font-size:13px;line-height:1">×</button>
</div>`;
}).join('')}`;
}
window.__tbDeleteBookmark = function (id) {
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
fetch('/api/textbooks/bookmarks/' + id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
}).then(r => {
if (r.ok) {
bookmarks = bookmarks.filter(b => b.id !== id);
renderBookmarksPanel();
}
}).catch(() => {});
};
/* Selection → "+ Закладка" floating button */
function installSelectionHandler() {
let btn = null;
document.addEventListener('mouseup', () => {
setTimeout(() => {
const sel = window.getSelection();
const text = (sel ? sel.toString() : '').trim();
if (!text || text.length < 8 || text.length > 400) { hideBtn(); return; }
if (sel.rangeCount === 0) { hideBtn(); return; }
const rect = sel.getRangeAt(0).getBoundingClientRect();
if (!rect || rect.width === 0) { hideBtn(); return; }
showBtn(rect, text);
}, 10);
});
document.addEventListener('mousedown', e => {
if (e.target.closest('#tb-sel-btn')) return;
hideBtn();
});
function showBtn(rect, text) {
if (!btn) {
btn = document.createElement('button');
btn.id = 'tb-sel-btn';
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg><span>Закладка</span>`;
Object.assign(btn.style, {
position: 'fixed', zIndex: '10000',
display: 'inline-flex', alignItems: 'center', gap: '5px',
padding: '6px 11px', borderRadius: '6px',
border: 'none', background: '#1c1917', color: '#fff',
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '12px', fontWeight: '700',
cursor: 'pointer', boxShadow: '0 4px 14px rgba(0,0,0,.3)',
});
document.body.appendChild(btn);
}
btn.style.top = (rect.top + window.scrollY - 36) + 'px';
btn.style.left = (rect.left + window.scrollX + rect.width / 2 - 50) + 'px';
btn.style.display = 'inline-flex';
btn.onclick = () => createBookmarkFromSelection(text);
}
function hideBtn() { if (btn) btn.style.display = 'none'; }
}
function createBookmarkFromSelection(text) {
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) {
alert('Сохранение закладок доступно после входа в систему');
return;
}
const note = prompt('Заметка к закладке (опционально):', '') || '';
// Find current paragraph from selection
const sel = window.getSelection();
let para = null;
if (sel.rangeCount > 0) {
let node = sel.getRangeAt(0).startContainer;
while (node && node !== document.body) {
if (node.dataset?.para) { para = node.dataset.para; break; }
if (node.id && /^p\d+$/.test(node.id)) { para = node.id; break; }
if (node.id && /^ptab-p\d+$/.test(node.id)) { para = node.id.replace('ptab-', ''); break; }
node = node.parentNode;
}
}
if (!para) para = localState.last; // fallback
fetch('/api/textbooks/' + slug + '/bookmarks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + LS.getToken(),
},
body: JSON.stringify({ text, note, para, color: 'yellow' }),
})
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d) return;
bookmarks.unshift({ id: d.id, text, note, para, color: 'yellow', created_at: new Date().toISOString() });
const btnEl = document.getElementById('tb-sel-btn');
if (btnEl) btnEl.style.display = 'none';
window.getSelection()?.removeAllRanges();
});
}
/* ── 9. Boot ──────────────────────────────────────────────────── */
function openParaByKey(key) {
if (!key) return;
if (typeof setParaTab === 'function') {
try { setParaTab(key); return; } catch {}
}
const pill = document.querySelector(`.para-pill[data-para="${key}"]`);
if (pill) pill.click();
}
function handleHashNav() {
const m = (location.hash || '').match(/^#(p\d+)$/);
if (m) {
const key = m[1];
openParaByKey(key);
localState.last = key;
if (!localState.read.includes(key)) {
localState.read.push(key);
refreshPillUI(key);
refreshCheckUI(key);
}
persist();
// Hash-вход (например, «Продолжить» из каталога) считается просмотром
// параграфа — шлём last_para + mark_read одним POST.
syncToServer({ mark_read: key });
return true;
}
return false;
}
function boot() {
console.log('[tracker] boot, slug =', slug, '| LS:', typeof LS !== 'undefined', '| token:', typeof LS !== 'undefined' && LS.getToken && !!LS.getToken());
injectStyles();
installBackButton();
installBookmarksBtn();
installBookmarksPanel();
installSelectionHandler();
installReadCheckboxes();
wirePillTracking();
refreshAllUI();
loadServerProgress();
loadBookmarks();
// Priority: URL hash > last visited paragraph
if (!handleHashNav() && localState.last) {
setTimeout(() => openParaByKey(localState.last), 50);
}
window.addEventListener('hashchange', handleHashNav);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();