и фиксируем как просмотр параграфа.
document.addEventListener('click', e => {
const tab = e.target && e.target.closest && e.target.closest('.tab[data-tab]');
if (!tab) return;
const m = String(tab.dataset.tab || '').match(/^ref(\d+)$/);
if (!m) return;
recordParaVisit('p' + m[1]);
}, true);
// Hook 5: polling — наблюдаем за классом .active на пилюлях.
// Срабатывает на любую программную смену активного параграфа.
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) {
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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
}
function installBookmarksBtn() {
if (document.getElementById('tb-bm-btn')) return;
const btn = document.createElement('button');
btn.id = 'tb-bm-btn';
btn.title = 'Мои закладки';
btn.innerHTML = ``;
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 = `
Закладок нет
Выдели любой текст в учебнике и нажми «+ Закладка»
`;
return;
}
panel.innerHTML = `
Мои закладки (${bookmarks.length})
${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 ? `§${b.para.replace('p','')}` : '';
return `
${paraLink}
«${escHtml(b.text)}»
${b.note ? `
${escHtml(b.note)}
` : ''}
`;
}).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 = `Закладка`;
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() {
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();
}
})();