585 lines
27 KiB
JavaScript
585 lines
27 KiB
JavaScript
'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 });
|
||
updateDebugBadge();
|
||
}
|
||
|
||
// DEBUG: визуальный бейдж прогресса в правом нижнем углу.
|
||
function ensureDebugBadge() {
|
||
if (document.getElementById('tb-debug-badge')) return;
|
||
const b = document.createElement('div');
|
||
b.id = 'tb-debug-badge';
|
||
b.style.cssText = 'position:fixed;bottom:12px;right:12px;z-index:99999;background:rgba(15,23,42,.92);color:#fff;padding:10px 14px;border-radius:10px;font-family:monospace;font-size:12px;line-height:1.5;box-shadow:0 4px 14px rgba(0,0,0,.3);max-width:260px';
|
||
document.body.appendChild(b);
|
||
updateDebugBadge();
|
||
}
|
||
function updateDebugBadge() {
|
||
const b = document.getElementById('tb-debug-badge');
|
||
if (!b) return;
|
||
const activeParaEl = document.querySelector('.para-pill.active[data-para]');
|
||
const active = activeParaEl ? activeParaEl.dataset.para : '?';
|
||
b.innerHTML =
|
||
'<div><b>tracker debug</b></div>' +
|
||
'<div>slug: ' + slug + '</div>' +
|
||
'<div>active pill: ' + active + '</div>' +
|
||
'<div>localState.last: ' + (localState.last || '?') + '</div>' +
|
||
'<div>read [' + localState.read.length + ']: ' + (localState.read.slice(-8).join(',') || '—') + '</div>' +
|
||
'<div style="opacity:.6;margin-top:4px">Кликай пилюли — должно меняться</div>';
|
||
}
|
||
|
||
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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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());
|
||
ensureDebugBadge();
|
||
setInterval(updateDebugBadge, 1000);
|
||
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();
|
||
}
|
||
})();
|