feat(textbooks): Wave 3 — UX-фичи Алгебры 8 (+636 строк)
1. Ctrl+K поиск: модалка со списком, индексирует параграфы, виджеты, карточки, термины глоссария. Стрелками выбор, Enter переход 2. Клавишные шорткаты: 1-7 → §§, ←/→ навигация, Esc закрыть модалки, ? показать справку. Игнорируется при фокусе в input 3. Закладки: SVG-кнопка в углу каждой .card (filled/outlined), хранятся в LocalStorage algebra8_bookmarks. В сайдбаре раздел 'Мои закладки' с переходом и удалением 4. Глоссарий-tooltips: 13 терминов (арифметический корень, радикал, иррациональное, модуль, промежуток, интервал, отрезок, система, совокупность, двойное неравенство и др.). DOM-walker оборачивает термины в .gloss с подчёркиванием, hover показывает определение в floating-tooltip 5. Mini-map: фиксированная панель справа с точкой на каждый .card/.wg в активной секции, активная подсвечивается по скроллу, скрывается на ≤980px 6. 3-уровневая подсказка: 'Подсказка' рядом с 'Проверить' в simp4 и compare. Уровень 1: намёк, 2: шаг, 3: полный ответ (−5 очков) 7. Шпаргалка drawer на мобильном: hamburger-кнопка в шапке, sidebar выезжает справа на ≤980px (transform translateX)
This commit is contained in:
@@ -475,6 +475,75 @@ input,select,textarea{font-family:inherit}
|
||||
/* Task 2: geo proof animation badge */
|
||||
.proof-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;background:linear-gradient(135deg,var(--ok),#059669);color:#fff;border-radius:8px;font-weight:700;font-size:.85rem;animation:badgeIn .4s cubic-bezier(.34,1.56,.64,1)}
|
||||
@keyframes badgeIn{from{transform:scale(.5);opacity:0}to{transform:scale(1);opacity:1}}
|
||||
|
||||
/* ═══════════════════════════════════════════════
|
||||
WAVE 3 — UX / NAVIGATION
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
/* Task 1: Ctrl+K Search modal */
|
||||
.search-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);z-index:9998;padding-top:80px}
|
||||
.search-modal.open{display:block;animation:fadeIn .2s ease}
|
||||
.search-box{max-width:560px;margin:0 auto;background:var(--card);border-radius:14px;box-shadow:0 16px 50px rgba(0,0,0,.4);overflow:hidden}
|
||||
.search-input{width:100%;padding:18px 22px;border:none;background:transparent;color:var(--text);font-size:1.05rem;font-family:inherit;outline:none;border-bottom:1px solid var(--border)}
|
||||
.search-results{max-height:50vh;overflow-y:auto}
|
||||
.search-result{padding:11px 22px;cursor:pointer;border-bottom:1px solid var(--border);transition:background .12s}
|
||||
.search-result:hover,.search-result.selected{background:var(--pri-soft)}
|
||||
.search-result-title{font-weight:700;font-size:.92rem;color:var(--text)}
|
||||
.search-result-sub{font-size:.78rem;color:var(--muted);margin-top:2px}
|
||||
.search-empty{padding:22px;text-align:center;color:var(--muted);font-size:.9rem}
|
||||
.search-hint-badge{padding:4px 8px;background:rgba(255,255,255,.18);border-radius:6px;font-size:.72rem;font-weight:700;letter-spacing:.04em}
|
||||
|
||||
/* Task 2: Shortcuts modal */
|
||||
.shortcuts-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);backdrop-filter:blur(3px);z-index:9998;align-items:center;justify-content:center}
|
||||
.shortcuts-modal.open{display:flex;animation:fadeIn .2s ease}
|
||||
.shortcuts-box{background:var(--card);border-radius:16px;padding:24px 28px;min-width:300px;max-width:400px;box-shadow:0 16px 50px rgba(0,0,0,.35)}
|
||||
.shortcuts-box h3{font-size:1rem;font-weight:800;color:var(--pri2);margin-bottom:14px;border-bottom:1px solid var(--border);padding-bottom:10px}
|
||||
.shortcut-row{display:flex;align-items:center;gap:10px;padding:6px 0;font-size:.88rem}
|
||||
.shortcut-key{display:inline-flex;align-items:center;justify-content:center;min-width:32px;padding:3px 8px;background:var(--pri-soft);border:1px solid var(--border);border-radius:5px;font-family:monospace;font-size:.82rem;font-weight:700;color:var(--pri2)}
|
||||
.shortcut-desc{color:var(--text);flex:1}
|
||||
|
||||
/* Task 3: Bookmarks */
|
||||
.bm-btn{position:absolute;top:10px;right:10px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:7px;background:transparent;color:var(--muted);transition:color .15s,background .15s;z-index:2;padding:0}
|
||||
.bm-btn:hover{background:var(--pri-soft);color:var(--pri)}
|
||||
.bm-btn.saved{color:var(--pri)}
|
||||
.bm-icon-outline{display:block}.bm-icon-filled{display:none}
|
||||
.bm-btn.saved .bm-icon-outline{display:none}.bm-btn.saved .bm-icon-filled{display:block}
|
||||
.sidecard-bm-row{display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px dashed var(--border);font-size:.8rem}
|
||||
.sidecard-bm-row:last-child{border-bottom:none}
|
||||
.sidecard-bm-title{flex:1;color:var(--text);font-weight:600;cursor:pointer}
|
||||
.sidecard-bm-title:hover{color:var(--pri);text-decoration:underline}
|
||||
.sidecard-bm-del{color:var(--fail);font-size:.78rem;cursor:pointer;padding:2px 5px;border-radius:4px}
|
||||
.sidecard-bm-del:hover{background:var(--fail-bg)}
|
||||
|
||||
/* Task 4: Glossary tooltips */
|
||||
.gloss{border-bottom:1.5px dashed var(--sec-acc,var(--pri));cursor:help;font-style:normal}
|
||||
.gloss-tip{position:fixed;max-width:280px;padding:10px 14px;background:var(--card);border:1px solid var(--sec-acc,var(--pri));border-radius:10px;font-size:.82rem;line-height:1.5;box-shadow:0 8px 24px rgba(0,0,0,0.15);z-index:9990;display:none;pointer-events:none}
|
||||
.gloss-tip.show{display:block;animation:tipIn .15s ease}
|
||||
@keyframes tipIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Task 5: Mini-map */
|
||||
.minimap{position:fixed;right:16px;bottom:80px;display:flex;flex-direction:column;gap:6px;z-index:50;background:var(--card);padding:8px;border-radius:10px;border:1px solid var(--border);box-shadow:var(--sh)}
|
||||
.mm-dot{width:10px;height:10px;border-radius:50%;background:rgba(0,0,0,.15);cursor:pointer;transition:transform .15s,background .15s}
|
||||
.dark .mm-dot{background:rgba(255,255,255,.2)}
|
||||
.mm-dot:hover{transform:scale(1.4);background:var(--sec-acc,var(--pri))}
|
||||
.mm-dot.active{background:var(--sec-acc,var(--pri));transform:scale(1.3)}
|
||||
@media(max-width:980px){.minimap{display:none}}
|
||||
|
||||
/* Task 6: Hint system */
|
||||
.hint-box{background:linear-gradient(135deg,var(--warn-bg),var(--pri-soft));border-left:4px solid var(--warn);padding:10px 14px;border-radius:9px;margin-top:10px;font-size:.88rem;animation:fadeIn .2s ease}
|
||||
.dark .hint-box{background:linear-gradient(135deg,rgba(245,158,11,.18),rgba(233,30,99,.12));color:var(--text)}
|
||||
.hint-level-badge{display:inline-block;padding:2px 7px;border-radius:5px;font-size:.72rem;font-weight:800;background:var(--warn);color:#451a03;margin-bottom:5px;letter-spacing:.04em}
|
||||
|
||||
/* Task 7: Mobile Cheatsheet Sidebar button */
|
||||
#sidebar-btn{display:none}
|
||||
@media(max-width:980px){
|
||||
#sidebar-btn{display:inline-flex}
|
||||
.col-side{position:fixed;right:0;top:0;width:300px;max-width:90vw;height:100vh;background:var(--card);box-shadow:-12px 0 32px rgba(0,0,0,.2);transform:translateX(100%);transition:transform .25s;z-index:1000;overflow-y:auto;padding:20px;display:block}
|
||||
.col-side.open{transform:translateX(0)}
|
||||
.col-side-close{display:block;position:absolute;top:12px;right:12px;z-index:2}
|
||||
.col-side.side-open{transform:translateX(0)}
|
||||
}
|
||||
@media(min-width:981px){.col-side-close{display:none}.col-side{transform:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -486,11 +555,18 @@ input,select,textarea{font-family:inherit}
|
||||
<div class="hdr-sub">Квадратные корни и их свойства. Действительные числа</div>
|
||||
</div>
|
||||
<div class="hdr-side">
|
||||
<input id="search-inp" class="hdr-search" type="text" placeholder="Поиск...">
|
||||
<button id="sidebar-btn" class="hdr-btn" onclick="toggleSidebar()" title="Шпаргалка">
|
||||
<svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg>
|
||||
Шпаргалка
|
||||
</button>
|
||||
<button id="side-open-btn" class="hdr-btn" onclick="openSidebar()" title="Шпаргалка" style="display:none">
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<span>Шпаргалка</span>
|
||||
</button>
|
||||
<button id="search-open-btn" class="hdr-btn" onclick="openSearch()" title="Поиск (Ctrl+K)">
|
||||
<svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
|
||||
<span class="search-hint-badge">Ctrl+K</span>
|
||||
</button>
|
||||
<button id="theme-btn" class="hdr-btn" title="Сменить тему">
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
<span id="theme-lab">Тёмная</span>
|
||||
@@ -588,7 +664,10 @@ input,select,textarea{font-family:inherit}
|
||||
|
||||
</div>
|
||||
|
||||
<aside class="col-side">
|
||||
<aside class="col-side" id="col-side">
|
||||
<button class="col-side-close btn small" onclick="toggleSidebar()" title="Закрыть" style="margin-bottom:10px">
|
||||
<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div id="sidebar-content"></div>
|
||||
</aside>
|
||||
</main>
|
||||
@@ -607,6 +686,34 @@ input,select,textarea{font-family:inherit}
|
||||
<span id="ach-text">Достижение!</span>
|
||||
</div>
|
||||
|
||||
<!-- Wave 3: Search modal -->
|
||||
<div id="search-modal" class="search-modal" onclick="if(event.target===this)closeSearch()">
|
||||
<div class="search-box">
|
||||
<input id="search-modal-input" class="search-input" type="text" placeholder="Поиск по параграфам, терминам, интерактивам..." autocomplete="off">
|
||||
<div id="search-results" class="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wave 3: Shortcuts modal -->
|
||||
<div id="shortcuts-modal" class="shortcuts-modal" onclick="if(event.target===this)closeShortcutsModal()">
|
||||
<div class="shortcuts-box">
|
||||
<h3>Горячие клавиши</h3>
|
||||
<div class="shortcut-row"><span class="shortcut-key">Ctrl+K</span><span class="shortcut-desc">Открыть поиск</span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-key">1–7</span><span class="shortcut-desc">Перейти к §1–§6 или Финалу</span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-key">←</span><span class="shortcut-desc">Предыдущий параграф</span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-key">→</span><span class="shortcut-desc">Следующий параграф</span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-key">Esc</span><span class="shortcut-desc">Закрыть модалку / поиск</span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-key">?</span><span class="shortcut-desc">Показать эту справку</span></div>
|
||||
<div style="margin-top:14px;text-align:right"><button class="btn small primary" onclick="closeShortcutsModal()">Закрыть</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wave 3: Glossary tooltip -->
|
||||
<div id="gloss-tip" class="gloss-tip"></div>
|
||||
|
||||
<!-- Wave 3: Minimap -->
|
||||
<div id="minimap" class="minimap" title="Мини-карта секции"></div>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
@@ -1100,7 +1207,7 @@ function openSidebar(){
|
||||
function closeSidebar(){
|
||||
const side = document.querySelector('.col-side');
|
||||
const overlay = document.getElementById('side-overlay');
|
||||
if(side){ side.classList.remove('side-open'); }
|
||||
if(side){ side.classList.remove('side-open','open'); }
|
||||
if(overlay){ overlay.classList.remove('show'); }
|
||||
}
|
||||
function initMobileSidebar(){
|
||||
@@ -2691,6 +2798,7 @@ function buildP4(){
|
||||
<span class="lab-mono" style="font-size:1.4rem">√</span>
|
||||
<input id="simp4-b" class="inp num" type="number" placeholder="b" style="width:60px">
|
||||
<button class="btn primary" onclick="simp4Check()">Проверить</button>
|
||||
<button class="btn" onclick="simp4Hint()">Подсказка</button>
|
||||
<button class="btn" onclick="simp4Next()">Дальше</button>
|
||||
</div>
|
||||
<div class="row-c" style="margin-top:10px">
|
||||
@@ -4189,5 +4297,533 @@ function finalSummary(){
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════
|
||||
WAVE 3 — UX / NAVIGATION
|
||||
════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ─── Task 1: Ctrl+K Search ─── */
|
||||
const SEARCH_INDEX = [];
|
||||
|
||||
function buildSearchIndex(){
|
||||
SEARCH_INDEX.length = 0;
|
||||
// 1. Параграфы
|
||||
PARAS.forEach(p=>{
|
||||
SEARCH_INDEX.push({ title: p.num + ' ' + p.name, sub: p.sub || '', action: ()=>goTo(p.id) });
|
||||
(p.topics||[]).forEach(t=>{
|
||||
SEARCH_INDEX.push({ title: t, sub: p.num + ' ' + p.name, action: ()=>goTo(p.id) });
|
||||
});
|
||||
});
|
||||
// 2. Интерактивы (wg-title из DOM)
|
||||
document.querySelectorAll('.wg').forEach(wg=>{
|
||||
const titleEl = wg.querySelector('.wg-title');
|
||||
const badgeEl = wg.querySelector('.wg-badge');
|
||||
if(titleEl){
|
||||
const sec = wg.closest('.sec');
|
||||
const secName = sec ? (sec.querySelector('.sec-h') ? sec.querySelector('.sec-h').textContent : '') : '';
|
||||
SEARCH_INDEX.push({
|
||||
title: titleEl.textContent.trim(),
|
||||
sub: (badgeEl ? badgeEl.textContent.trim() + ' · ' : '') + secName,
|
||||
action: ()=>{
|
||||
const secId = sec ? sec.id.replace('sec-','') : null;
|
||||
if(secId) goTo(secId);
|
||||
setTimeout(()=>wg.scrollIntoView({behavior:'smooth',block:'center'}), 350);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// 3. Карточки (card-title)
|
||||
document.querySelectorAll('.card').forEach(card=>{
|
||||
const titleEl = card.querySelector('.card-title');
|
||||
if(titleEl){
|
||||
const sec = card.closest('.sec');
|
||||
const secName = sec ? (sec.querySelector('.sec-h') ? sec.querySelector('.sec-h').textContent : '') : '';
|
||||
SEARCH_INDEX.push({
|
||||
title: titleEl.textContent.trim(),
|
||||
sub: secName,
|
||||
action: ()=>{
|
||||
const secId = sec ? sec.id.replace('sec-','') : null;
|
||||
if(secId) goTo(secId);
|
||||
setTimeout(()=>card.scrollIntoView({behavior:'smooth',block:'center'}), 350);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// 4. Ключевые термины глоссария
|
||||
Object.entries(GLOSSARY).forEach(([term, def])=>{
|
||||
SEARCH_INDEX.push({
|
||||
title: term,
|
||||
sub: def.length > 60 ? def.slice(0,60) + '…' : def,
|
||||
action: ()=>{}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let _searchIdx = -1;
|
||||
|
||||
function openSearch(){
|
||||
const modal = document.getElementById('search-modal');
|
||||
if(!modal) return;
|
||||
modal.classList.add('open');
|
||||
const inp = document.getElementById('search-modal-input');
|
||||
if(inp){ inp.value = ''; inp.focus(); }
|
||||
_searchIdx = -1;
|
||||
_renderSearchResults('');
|
||||
}
|
||||
|
||||
function closeSearch(){
|
||||
const modal = document.getElementById('search-modal');
|
||||
if(modal) modal.classList.remove('open');
|
||||
}
|
||||
|
||||
function _renderSearchResults(q){
|
||||
const box = document.getElementById('search-results');
|
||||
if(!box) return;
|
||||
const trimmed = q.trim().toLowerCase();
|
||||
let items = SEARCH_INDEX;
|
||||
if(trimmed){
|
||||
items = SEARCH_INDEX.filter(it=>{
|
||||
return it.title.toLowerCase().includes(trimmed) || it.sub.toLowerCase().includes(trimmed);
|
||||
}).slice(0, 18);
|
||||
} else {
|
||||
items = SEARCH_INDEX.slice(0, 12);
|
||||
}
|
||||
if(!items.length){
|
||||
box.innerHTML = '<div class="search-empty">Ничего не найдено</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = items.map((it, i)=>`
|
||||
<div class="search-result" data-idx="${i}" onclick="_searchPick(${i})">
|
||||
<div class="search-result-title">${_highlightMatch(it.title, trimmed)}</div>
|
||||
${it.sub ? `<div class="search-result-sub">${it.sub}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
box.querySelectorAll('.search-result').forEach((el,i)=>{ el._searchItem = items[i]; });
|
||||
}
|
||||
|
||||
function _highlightMatch(text, q){
|
||||
if(!q) return text;
|
||||
const idx = text.toLowerCase().indexOf(q);
|
||||
if(idx < 0) return text;
|
||||
return text.slice(0,idx) + '<mark style="background:var(--warn-bg);border-radius:3px">' + text.slice(idx, idx+q.length) + '</mark>' + text.slice(idx+q.length);
|
||||
}
|
||||
|
||||
function _searchPick(i){
|
||||
const box = document.getElementById('search-results');
|
||||
if(!box) return;
|
||||
const items = [...box.querySelectorAll('.search-result')];
|
||||
if(items[i] && items[i]._searchItem){
|
||||
items[i]._searchItem.action();
|
||||
closeSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function _initSearchInput(){
|
||||
const inp = document.getElementById('search-modal-input');
|
||||
if(!inp) return;
|
||||
inp.addEventListener('input', ()=>{ _searchIdx = -1; _renderSearchResults(inp.value); });
|
||||
inp.addEventListener('keydown', e=>{
|
||||
const box = document.getElementById('search-results');
|
||||
const rows = box ? [...box.querySelectorAll('.search-result')] : [];
|
||||
if(e.key === 'ArrowDown'){
|
||||
e.preventDefault();
|
||||
_searchIdx = Math.min(_searchIdx + 1, rows.length - 1);
|
||||
} else if(e.key === 'ArrowUp'){
|
||||
e.preventDefault();
|
||||
_searchIdx = Math.max(_searchIdx - 1, 0);
|
||||
} else if(e.key === 'Enter'){
|
||||
e.preventDefault();
|
||||
if(_searchIdx >= 0 && rows[_searchIdx] && rows[_searchIdx]._searchItem){
|
||||
rows[_searchIdx]._searchItem.action();
|
||||
closeSearch();
|
||||
} else if(rows[0] && rows[0]._searchItem){
|
||||
rows[0]._searchItem.action();
|
||||
closeSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
rows.forEach((r,i)=>r.classList.toggle('selected', i === _searchIdx));
|
||||
if(rows[_searchIdx]) rows[_searchIdx].scrollIntoView({block:'nearest'});
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── Task 2: Keyboard shortcuts ─── */
|
||||
const PARA_ORDER = ['p1','p2','p3','p4','p5','p6','final'];
|
||||
|
||||
function navNext(){
|
||||
const idx = PARA_ORDER.indexOf(STATE.current);
|
||||
if(idx >= 0 && idx < PARA_ORDER.length - 1) goTo(PARA_ORDER[idx + 1]);
|
||||
}
|
||||
|
||||
function navPrev(){
|
||||
const idx = PARA_ORDER.indexOf(STATE.current);
|
||||
if(idx > 0) goTo(PARA_ORDER[idx - 1]);
|
||||
}
|
||||
|
||||
function showShortcutsHelp(){
|
||||
const m = document.getElementById('shortcuts-modal');
|
||||
if(m) m.classList.add('open');
|
||||
}
|
||||
|
||||
function closeShortcutsModal(){
|
||||
const m = document.getElementById('shortcuts-modal');
|
||||
if(m) m.classList.remove('open');
|
||||
}
|
||||
|
||||
function _initKeyboard(){
|
||||
document.addEventListener('keydown', e=>{
|
||||
// Ctrl+K / Cmd+K — search
|
||||
if((e.ctrlKey || e.metaKey) && e.key === 'k'){
|
||||
e.preventDefault();
|
||||
openSearch();
|
||||
return;
|
||||
}
|
||||
// Escape — close modals
|
||||
if(e.key === 'Escape'){
|
||||
closeSearch();
|
||||
closeShortcutsModal();
|
||||
return;
|
||||
}
|
||||
// Ignore if inside input
|
||||
const tag = e.target.tagName;
|
||||
if(tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
// ? — shortcuts help
|
||||
if(e.key === '?' || (e.shiftKey && e.key === '/')){
|
||||
showShortcutsHelp();
|
||||
return;
|
||||
}
|
||||
// 1-7 — go to para
|
||||
const map = {'1':'p1','2':'p2','3':'p3','4':'p4','5':'p5','6':'p6','7':'final'};
|
||||
if(map[e.key]){ goTo(map[e.key]); return; }
|
||||
// Arrow keys — prev/next
|
||||
if(e.key === 'ArrowRight'){ navNext(); return; }
|
||||
if(e.key === 'ArrowLeft'){ navPrev(); return; }
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── Task 3: Bookmarks ─── */
|
||||
const BM_KEY = 'algebra8_bookmarks';
|
||||
|
||||
function loadBookmarks(){
|
||||
try{ return JSON.parse(localStorage.getItem(BM_KEY) || '[]'); }catch(e){ return []; }
|
||||
}
|
||||
|
||||
function saveBookmarks(bms){
|
||||
try{ localStorage.setItem(BM_KEY, JSON.stringify(bms)); }catch(e){}
|
||||
}
|
||||
|
||||
function _getCardId(card){
|
||||
const sec = card.closest('.sec');
|
||||
const secId = sec ? sec.id : 'unknown';
|
||||
const allCards = sec ? [...sec.querySelectorAll('.card')] : [];
|
||||
const idx = allCards.indexOf(card);
|
||||
return secId + ':card-' + idx;
|
||||
}
|
||||
|
||||
function _getCardTitle(card){
|
||||
const t = card.querySelector('.card-title');
|
||||
return t ? t.textContent.trim() : 'Карточка';
|
||||
}
|
||||
|
||||
function _isBookmarked(id){
|
||||
return loadBookmarks().some(b=>b.id === id);
|
||||
}
|
||||
|
||||
function toggleBookmark(btn, card){
|
||||
const id = _getCardId(card);
|
||||
const title = _getCardTitle(card);
|
||||
const sec = card.closest('.sec');
|
||||
const para = sec ? sec.id.replace('sec-','') : 'p1';
|
||||
let bms = loadBookmarks();
|
||||
if(_isBookmarked(id)){
|
||||
bms = bms.filter(b=>b.id !== id);
|
||||
btn.classList.remove('saved');
|
||||
} else {
|
||||
bms.push({ id, title, para });
|
||||
btn.classList.add('saved');
|
||||
}
|
||||
saveBookmarks(bms);
|
||||
buildSidebar(STATE.current);
|
||||
}
|
||||
|
||||
function _attachBookmarkButtons(){
|
||||
document.querySelectorAll('.card').forEach(card=>{
|
||||
if(card.querySelector('.bm-btn')) return; // already added
|
||||
const id = _getCardId(card);
|
||||
const saved = _isBookmarked(id);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'bm-btn' + (saved ? ' saved' : '');
|
||||
btn.title = 'Закладка';
|
||||
btn.innerHTML = `
|
||||
<svg class="ic bm-icon-outline" viewBox="0 0 24 24" style="width:16px;height:16px"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
<svg class="ic bm-icon-filled" viewBox="0 0 24 24" style="width:16px;height:16px;fill:var(--pri);stroke:var(--pri)"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
`;
|
||||
btn.addEventListener('click', e=>{ e.stopPropagation(); toggleBookmark(btn, card); });
|
||||
card.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
/* Patch buildSidebar to include bookmarks section */
|
||||
const _origBuildSidebar = window.buildSidebar;
|
||||
window.buildSidebar = function(id){
|
||||
_origBuildSidebar(id);
|
||||
const box = document.getElementById('sidebar-content');
|
||||
const bms = loadBookmarks();
|
||||
if(bms.length > 0){
|
||||
let html = `<div class="sidecard"><h4>Мои закладки <span style="color:var(--pri);float:right">${bms.length}</span></h4>`;
|
||||
bms.slice().reverse().slice(0,8).forEach(b=>{
|
||||
const PNAMES = {p1:'§1',p2:'§2',p3:'§3',p4:'§4',p5:'§5',p6:'§6',final:'Финал'};
|
||||
html += `<div class="sidecard-bm-row">
|
||||
<span class="sidecard-bm-title" onclick="goTo('${b.para}')" title="Перейти">${b.title} <small style="color:var(--muted)">${PNAMES[b.para]||b.para}</small></span>
|
||||
<span class="sidecard-bm-del" onclick="_deleteBm('${b.id}')" title="Удалить">✕</span>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
box.insertAdjacentHTML('afterbegin', html);
|
||||
}
|
||||
};
|
||||
|
||||
function _deleteBm(id){
|
||||
saveBookmarks(loadBookmarks().filter(b=>b.id !== id));
|
||||
// Update bookmark button state if card is visible
|
||||
document.querySelectorAll('.bm-btn').forEach(btn=>{
|
||||
const card = btn.closest('.card');
|
||||
if(card && _getCardId(card) === id) btn.classList.remove('saved');
|
||||
});
|
||||
buildSidebar(STATE.current);
|
||||
}
|
||||
|
||||
/* ─── Task 4: Glossary tooltips ─── */
|
||||
const GLOSSARY = {
|
||||
'арифметический корень': 'Неотрицательное число, квадрат которого равен подкоренному. Обозначается √a, где a ≥ 0.',
|
||||
'радикал': 'Знак квадратного корня √. От лат. radix — корень.',
|
||||
'подкоренное': 'Выражение под знаком корня. Должно быть неотрицательным для извлечения арифм. корня.',
|
||||
'иррациональное число': 'Число, которое нельзя представить в виде m/n. Бесконечная непериодическая десятичная дробь. Примеры: √2, π.',
|
||||
'рациональное число': 'Число вида m/n, где m целое, n натуральное. Конечная или периодическая десятичная.',
|
||||
'действительное число': 'Любое число на координатной прямой. Объединение рациональных и иррациональных. Обозначается ℝ.',
|
||||
'модуль': 'Расстояние от числа до 0 на координатной прямой. |a| = a при a≥0, |a| = -a при a<0.',
|
||||
'промежуток': 'Часть числовой прямой между двумя точками или один луч.',
|
||||
'интервал': 'Открытый промежуток (a; b) — концы не включены.',
|
||||
'отрезок': 'Закрытый промежуток [a; b] — концы включены.',
|
||||
'система неравенств': 'Несколько неравенств с общим решением (пересечение). Обозначается {.',
|
||||
'совокупность': 'Несколько неравенств; решение — объединение. Обозначается [.',
|
||||
'двойное неравенство': 'Запись типа a < x < b — эквивалентна системе { x > a; x < b }.',
|
||||
};
|
||||
|
||||
function _applyGlossary(root){
|
||||
if(!root) return;
|
||||
const terms = Object.keys(GLOSSARY).sort((a,b)=>b.length-a.length);
|
||||
const re = new RegExp('(' + terms.map(t=>t.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('|') + ')', 'gi');
|
||||
// Only process text nodes in card-body and wg (skip scripts, inputs, already-marked)
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node){
|
||||
const p = node.parentElement;
|
||||
if(!p) return NodeFilter.FILTER_REJECT;
|
||||
const tag = p.tagName;
|
||||
if(['SCRIPT','STYLE','INPUT','TEXTAREA','SELECT'].includes(tag)) return NodeFilter.FILTER_REJECT;
|
||||
if(p.classList && p.classList.contains('gloss')) return NodeFilter.FILTER_REJECT;
|
||||
if(p.classList && (p.classList.contains('katex') || p.classList.contains('katex-html'))) return NodeFilter.FILTER_REJECT;
|
||||
// Only inside .card-body or .wg
|
||||
if(!p.closest('.card-body') && !p.closest('.wg')) return NodeFilter.FILTER_REJECT;
|
||||
if(!re.test(node.textContent)) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
});
|
||||
const nodes = [];
|
||||
let n;
|
||||
while((n = walker.nextNode())) nodes.push(n);
|
||||
nodes.forEach(node=>{
|
||||
re.lastIndex = 0;
|
||||
const parts = node.textContent.split(re);
|
||||
if(parts.length < 2) return;
|
||||
const frag = document.createDocumentFragment();
|
||||
parts.forEach(part=>{
|
||||
re.lastIndex = 0;
|
||||
if(re.test(part)){
|
||||
const termKey = Object.keys(GLOSSARY).find(k=>k.toLowerCase() === part.toLowerCase());
|
||||
const span = document.createElement('span');
|
||||
span.className = 'gloss';
|
||||
span.dataset.term = termKey || part.toLowerCase();
|
||||
span.textContent = part;
|
||||
frag.appendChild(span);
|
||||
} else {
|
||||
frag.appendChild(document.createTextNode(part));
|
||||
}
|
||||
re.lastIndex = 0;
|
||||
});
|
||||
node.parentNode.replaceChild(frag, node);
|
||||
});
|
||||
}
|
||||
|
||||
function _initGlossTooltip(){
|
||||
const tip = document.getElementById('gloss-tip');
|
||||
if(!tip) return;
|
||||
document.addEventListener('mouseover', e=>{
|
||||
const gloss = e.target.closest('.gloss');
|
||||
if(!gloss){ return; }
|
||||
const term = gloss.dataset.term;
|
||||
const def = GLOSSARY[term] || GLOSSARY[Object.keys(GLOSSARY).find(k=>k.toLowerCase()===term)] || '';
|
||||
if(!def) return;
|
||||
tip.textContent = def;
|
||||
tip.classList.add('show');
|
||||
const r = gloss.getBoundingClientRect();
|
||||
let top = r.bottom + 6;
|
||||
let left = r.left;
|
||||
if(top + 120 > window.innerHeight) top = r.top - 120;
|
||||
if(left + 290 > window.innerWidth) left = window.innerWidth - 296;
|
||||
if(left < 4) left = 4;
|
||||
tip.style.top = top + 'px';
|
||||
tip.style.left = left + 'px';
|
||||
});
|
||||
document.addEventListener('mouseout', e=>{
|
||||
if(!e.target.closest('.gloss')) tip.classList.remove('show');
|
||||
else if(e.relatedTarget && !e.relatedTarget.closest('.gloss')) tip.classList.remove('show');
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── Task 5: Mini-map ─── */
|
||||
let _mmScrollHandler = null;
|
||||
|
||||
function _buildMinimap(secId){
|
||||
const mm = document.getElementById('minimap');
|
||||
if(!mm) return;
|
||||
const sec = document.getElementById('sec-' + secId);
|
||||
if(!sec){ mm.innerHTML = ''; return; }
|
||||
const targets = [...sec.querySelectorAll('.card, .wg')];
|
||||
if(targets.length < 2){ mm.innerHTML = ''; return; }
|
||||
mm.innerHTML = '';
|
||||
targets.forEach((el, i)=>{
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'mm-dot';
|
||||
const kindBadge = el.classList.contains('wg') ? 'wg' : 'card';
|
||||
const titleEl = el.querySelector('.card-title, .wg-title');
|
||||
dot.title = titleEl ? titleEl.textContent.trim() : kindBadge + ' ' + (i+1);
|
||||
if(kindBadge === 'wg') dot.style.borderRadius = '3px';
|
||||
dot.addEventListener('click', ()=>el.scrollIntoView({behavior:'smooth',block:'center'}));
|
||||
mm.appendChild(dot);
|
||||
});
|
||||
// Update active dot on scroll
|
||||
if(_mmScrollHandler) window.removeEventListener('scroll', _mmScrollHandler);
|
||||
_mmScrollHandler = ()=>{
|
||||
const dots = [...mm.querySelectorAll('.mm-dot')];
|
||||
const midY = window.scrollY + window.innerHeight * 0.5;
|
||||
let activeI = 0;
|
||||
targets.forEach((el, i)=>{
|
||||
const r = el.getBoundingClientRect();
|
||||
if(r.top + window.scrollY <= midY) activeI = i;
|
||||
});
|
||||
dots.forEach((d,i)=>d.classList.toggle('active', i === activeI));
|
||||
};
|
||||
window.addEventListener('scroll', _mmScrollHandler, { passive:true });
|
||||
setTimeout(_mmScrollHandler, 100);
|
||||
}
|
||||
|
||||
/* ─── Task 6: Hint system for simp4 & comp ─── */
|
||||
/* Enhanced SIMP4_TASKS with hints */
|
||||
const SIMP4_HINTS = [
|
||||
['Ищи точный квадрат в 72', '72 = 36 × 2', '√72 = 6√2'],
|
||||
['Ищи точный квадрат в 50', '50 = 25 × 2', '√50 = 5√2'],
|
||||
['Ищи точный квадрат в 48', '48 = 16 × 3', '√48 = 4√3'],
|
||||
['Ищи точный квадрат в 200', '200 = 100 × 2', '√200 = 10√2'],
|
||||
['Ищи точный квадрат в 75', '75 = 25 × 3', '√75 = 5√3'],
|
||||
['Ищи точный квадрат в 98', '98 = 49 × 2', '√98 = 7√2'],
|
||||
['Ищи точный квадрат в 18', '18 = 9 × 2', '√18 = 3√2'],
|
||||
['Ищи точный квадрат в 128', '128 = 64 × 2', '√128 = 8√2'],
|
||||
['Ищи точный квадрат в 80', '80 = 16 × 5', '√80 = 4√5'],
|
||||
['Ищи точный квадрат в 108', '108 = 36 × 3', '√108 = 6√3'],
|
||||
['Ищи точный квадрат в 147', '147 = 49 × 3', '√147 = 7√3'],
|
||||
];
|
||||
const _simp4HintLevel = {};
|
||||
|
||||
function simp4Hint(){
|
||||
const idx = simp4State.idx;
|
||||
const key = 'simp4_' + idx;
|
||||
const level = (_simp4HintLevel[key] || 0);
|
||||
const hints = SIMP4_HINTS[idx] || [];
|
||||
const hintText = hints[level] || hints[hints.length - 1] || '—';
|
||||
const nextLevel = Math.min(level + 1, hints.length - 1);
|
||||
_simp4HintLevel[key] = nextLevel;
|
||||
let box = document.getElementById('simp4-hint-box');
|
||||
if(!box){
|
||||
box = document.createElement('div');
|
||||
box.id = 'simp4-hint-box';
|
||||
const fb = document.getElementById('simp4-fb');
|
||||
if(fb) fb.parentNode.insertBefore(box, fb);
|
||||
}
|
||||
const levelNames = ['Намёк','Шаг','Ответ'];
|
||||
box.className = 'hint-box';
|
||||
box.innerHTML = `<span class="hint-level-badge">Подсказка ${level + 1}: ${levelNames[level] || 'Ответ'}</span><br>${hintText}`;
|
||||
if(level === 2) simp4State.score = Math.max(0, simp4State.score - 5);
|
||||
}
|
||||
|
||||
const COMP_HINTS = [
|
||||
['Возведи оба в квадрат', '(3√2)² = 18, (2√3)² = 12', '18 > 12 → 3√2 > 2√3'],
|
||||
['Возведи оба в квадрат', '(4√3)² = 48, (3√5)² = 45', '48 > 45 → 4√3 > 3√5'],
|
||||
['Возведи оба в квадрат', '(5√2)² = 50, 7² = 49', '50 > 49 → 5√2 > 7'],
|
||||
['Возведи оба в квадрат', '(2√7)² = 28, (3√3)² = 27', '28 > 27 → 2√7 > 3√3'],
|
||||
['Возведи оба в квадрат', '(√17)² = 17, 4² = 16', '17 > 16 → √17 > 4'],
|
||||
['Возведи оба в квадрат', '(√35)² = 35, 6² = 36', '35 < 36 → √35 < 6'],
|
||||
];
|
||||
const _compHintLevel = {};
|
||||
|
||||
function compHint(){
|
||||
const key = 'comp_' + compIdx;
|
||||
const level = (_compHintLevel[key] || 0);
|
||||
const hints = COMP_HINTS[compIdx] || [];
|
||||
const hintText = hints[level] || hints[hints.length-1] || '—';
|
||||
_compHintLevel[key] = Math.min(level + 1, hints.length - 1);
|
||||
let box = document.getElementById('comp-hint-box');
|
||||
if(!box){
|
||||
box = document.createElement('div');
|
||||
box.id = 'comp-hint-box';
|
||||
const fb = document.getElementById('comp-fb');
|
||||
if(fb) fb.parentNode.insertBefore(box, fb);
|
||||
}
|
||||
const levelNames = ['Намёк','Шаг','Ответ'];
|
||||
box.className = 'hint-box';
|
||||
box.innerHTML = `<span class="hint-level-badge">Подсказка ${level + 1}: ${levelNames[level] || 'Ответ'}</span><br>${hintText}`;
|
||||
}
|
||||
|
||||
/* ─── Task 7: Mobile sidebar toggle ─── */
|
||||
function toggleSidebar(){
|
||||
const side = document.getElementById('col-side');
|
||||
if(!side) return;
|
||||
const isOpen = side.classList.contains('open') || side.classList.contains('side-open');
|
||||
if(isOpen){
|
||||
side.classList.remove('open','side-open');
|
||||
const overlay = document.getElementById('side-overlay');
|
||||
if(overlay) overlay.classList.remove('show');
|
||||
} else {
|
||||
side.classList.add('open','side-open');
|
||||
const overlay = document.getElementById('side-overlay');
|
||||
if(overlay) overlay.classList.add('show');
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Patch goTo to trigger Wave 3 post-build ─── */
|
||||
const _origGoToFinish = window._goToFinish;
|
||||
window._goToFinish = function(id){
|
||||
_origGoToFinish(id);
|
||||
// Build minimap after section is rendered
|
||||
setTimeout(()=>{
|
||||
_buildMinimap(id);
|
||||
_attachBookmarkButtons();
|
||||
_applyGlossary(document.getElementById('sec-' + id));
|
||||
}, 80);
|
||||
};
|
||||
|
||||
/* ─── Wave 3 INIT ─── */
|
||||
function initWave3(){
|
||||
_initKeyboard();
|
||||
_initSearchInput();
|
||||
_initGlossTooltip();
|
||||
// Rebuild search index after everything is built
|
||||
setTimeout(buildSearchIndex, 1200);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', ()=>setTimeout(initWave3, 100));
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user