feat(algebra-8 ch2): Wave 5 — глоссарий-тултипы + поиск Ctrl+K

GLOSSARY: 13 ключевых терминов (квадратное уравнение, дискриминант,
теорема Виета, биквадратное, ОДЗ, посторонний корень и др.) с
определениями в KaTeX и привязкой к параграфу.

- wrapGlossary(root): обходит текстовые узлы секции, оборачивает
  совпадения регулярным выражением по всем алиасам. Игнорирует
  KaTeX-узлы, кнопки, инпуты, сайдбары, шапку, поп-апы.
- Падеж-алиасы для каждого термина (дискриминант / дискриминанта /
  дискриминантом / дискриминанте).
- Подчёркнутый пунктиром термин при ховере / клике показывает
  плавающий tooltip с определением и ссылкой на параграф.
- Запускается после goTo() с задержкой 60мс.

SEARCH (Ctrl+K):
- Кнопка «Поиск» в шапке + хоткей Ctrl+K (cmd+K на Mac).
- Индекс: 7 параграфов + 13 терминов глоссария + 5 ключевых формул
  + Финал главы.
- Скоринг: title contains > startsWith bonus > word match.
- Стрелки ↑↓ / Enter / Esc / клик мышью.
- При выборе термина — переход в его параграф + scrollIntoView
  + жёлтая подсветка 1.4с.

Стили: .gloss-term пунктирное подчёркивание, .gloss-tip floating card,
.search-modal с blur backdrop, .search-row с hover/active.
This commit is contained in:
Maxim Dolgolyov
2026-05-27 15:31:35 +03:00
parent 0cd187b693
commit e21b12a7ce
+226
View File
@@ -266,6 +266,29 @@ input,select,textarea{font-family:inherit}
.eq-show{font-family:'JetBrains Mono',monospace}
.pipe-tabs .btn.active{background:var(--sec-acc,var(--pri));color:#fff;border-color:var(--sec-acc,var(--pri))}
/* GLOSSARY tooltip */
.gloss-term{border-bottom:1.5px dotted var(--sec-acc,var(--pri));cursor:help;color:var(--sec-acc-d,var(--pri2));font-weight:600;padding:0 1px}
.gloss-term:hover{background:var(--sec-acc-soft,var(--pri-soft));border-radius:3px}
.gloss-tip{position:fixed;max-width:320px;padding:11px 14px;background:var(--card);border:1.5px solid var(--sec-acc,var(--pri));border-radius:11px;font-size:.84rem;line-height:1.55;box-shadow:0 12px 32px rgba(0,0,0,.18);z-index:9994;display:none;pointer-events:none;color:var(--text)}
.gloss-tip.show{display:block;animation:tipIn .15s ease}
.gloss-tip b{color:var(--sec-acc-d,var(--pri2));font-size:.92rem}
@keyframes tipIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
/* SEARCH MODAL */
.search-modal{position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(4px);z-index:9993;display:none;align-items:flex-start;justify-content:center;padding-top:14vh}
.search-modal.show{display:flex;animation:fadeIn .15s ease}
.search-box{background:var(--bg);border:1px solid var(--border);border-radius:14px;width:560px;max-width:92vw;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.4)}
.search-input{padding:14px 16px;font-size:1rem;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--text);outline:none}
.search-results{flex:1;overflow-y:auto;padding:6px 0}
.search-row{display:block;padding:8px 16px;cursor:pointer;border-bottom:1px solid var(--border);text-align:left;background:transparent;border-left:0;border-right:0;border-top:0;width:100%;color:var(--text)}
.search-row:hover,.search-row.active{background:var(--sec-acc-soft,var(--pri-soft))}
.search-row .sr-kind{font-size:.7rem;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:2px}
.search-row .sr-title{font-weight:700;font-size:.92rem;color:var(--text)}
.search-row .sr-desc{font-size:.8rem;color:var(--muted);margin-top:2px}
.search-empty{padding:20px;text-align:center;color:var(--muted);font-size:.88rem}
.search-foot{padding:8px 14px;border-top:1px solid var(--border);font-size:.74rem;color:var(--muted);display:flex;gap:14px;background:var(--card-soft,transparent)}
.search-foot kbd{padding:2px 6px;background:var(--card);border:1px solid var(--border);border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:.72rem}
/* DRAG & DROP — sortable chips */
.dnd-pool{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px;padding:10px;border:1.5px dashed var(--border);border-radius:10px;min-height:54px;transition:border-color .18s,background .18s}
.dnd-pool.over{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft));border-style:solid}
@@ -305,6 +328,10 @@ input,select,textarea{font-family:inherit}
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
Глава 1
</a>
<button id="search-btn" class="hdr-btn" 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>Поиск</span>
</button>
<button id="sidebar-btn" class="hdr-btn" 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>
Шпаргалка
@@ -389,6 +416,20 @@ input,select,textarea{font-family:inherit}
<span id="ach-text">Достижение!</span>
</div>
<div id="gloss-tip" class="gloss-tip"></div>
<div id="search-modal" class="search-modal" role="dialog" aria-label="Поиск по главе">
<div class="search-box">
<input type="text" id="search-input" class="search-input" placeholder="Поиск: понятие, формула, параграф…" autocomplete="off">
<div id="search-results" class="search-results"></div>
<div class="search-foot">
<span><kbd>↑↓</kbd> навигация</span>
<span><kbd>Enter</kbd> открыть</span>
<span><kbd>Esc</kbd> закрыть</span>
</div>
</div>
</div>
<script>
'use strict';
@@ -550,6 +591,8 @@ function goTo(id){
window.scrollTo({top:0, behavior:'smooth'});
if((STATE.progress[id]||0) < 10) bumpProgress(id, 10);
if(window.renderMathInElement) setTimeout(()=>renderMath(el), 0);
// glossary wrap — пройти по тексту секции и обернуть термины
setTimeout(()=>{ try { wrapGlossary(el); } catch(e){} }, 60);
}
/* SIDEBAR */
@@ -860,6 +903,187 @@ function setupSorter(cfg){
}
const DND_HINT_HTML = '<div class="dnd-hint"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11V6a3 3 0 0 1 6 0v5"/><path d="M9 11h6v8a4 4 0 0 1-8 0z"/></svg> Перетащите карточку или нажмите её, затем — на нужный ящик.</div>';
/* ============================================================
GLOSSARY — term → definition map, hover-tooltip on .gloss-term
============================================================ */
const GLOSSARY = [
{ term:'квадратное уравнение', def:'Уравнение вида $ax^2+bx+c=0$ с $a \\neq 0$.', sec:'p7', aliases:['квадратное уравнение','квадратного уравнения','квадратные уравнения','квадратных уравнений'] },
{ term:'неполное квадратное уравнение', def:'Уравнение $ax^2+bx+c=0$, у которого $b=0$ или $c=0$ (или оба).', sec:'p7', aliases:['неполное квадратное','неполные квадратные','неполное уравнение','неполного уравнения','неполных уравнений'] },
{ term:'приведённое уравнение', def:'Квадратное уравнение со старшим коэффициентом 1: $x^2+px+q=0$.', sec:'p9', aliases:['приведённое','приведённого','приведённые','приведённых'] },
{ term:'дискриминант', def:'$D = b^2 - 4ac$. По знаку $D$ — число корней: $D>0$ — два, $D=0$ — один, $D<0$ — нет.', sec:'p8', aliases:['дискриминант','дискриминанта','дискриминантом','дискриминанте'] },
{ term:'теорема Виета', def:'Для $x^2+px+q=0$: $x_1+x_2=-p,\\ x_1 x_2 = q$. Для $ax^2+bx+c=0$: $-b/a,\\ c/a$.', sec:'p9', aliases:['теорема Виета','теоремы Виета','теореме Виета','Виета'] },
{ term:'корень уравнения', def:'Значение переменной, при котором уравнение становится верным равенством.', sec:'p7', aliases:['корень уравнения','корни уравнения','корня уравнения'] },
{ term:'квадратный трёхчлен', def:'Многочлен $ax^2+bx+c$ при $a \\neq 0$.', sec:'p10', aliases:['квадратный трёхчлен','квадратного трёхчлена','квадратные трёхчлены','квадратных трёхчленов'] },
{ term:'разложение на множители', def:'$ax^2+bx+c = a(x-x_1)(x-x_2)$ при $D \\geq 0$.', sec:'p10', aliases:['разложение на множители','разложения на множители','разложить на множители'] },
{ term:'биквадратное уравнение', def:'Уравнение $ax^4+bx^2+c=0$. Решается заменой $t=x^2,\\ t \\geq 0$.', sec:'p12', aliases:['биквадратное','биквадратных','биквадратные','биквадратного'] },
{ term:'ОДЗ', def:'Область допустимых значений. Для дробей — знаменатель $\\neq 0$.', sec:'p12', aliases:['ОДЗ','область допустимых значений'] },
{ term:'посторонний корень', def:'Корень, полученный при решении, но не удовлетворяющий ОДЗ исходного уравнения.', sec:'p12', aliases:['посторонний корень','посторонние корни','постороннего корня'] },
{ term:'свободный член', def:'Коэффициент $c$ в $ax^2+bx+c$. Член без переменной.', sec:'p7', aliases:['свободный член','свободного члена'] },
{ term:'старший коэффициент', def:'Коэффициент $a$ при $x^2$ в квадратном уравнении.', sec:'p7', aliases:['старший коэффициент','старшего коэффициента'] },
];
function wrapGlossary(root){
if(!root || root.__glossDone) return;
const allAliases = [];
GLOSSARY.forEach((g, i) => g.aliases.forEach(a => allAliases.push({ a, i })));
allAliases.sort((x, y) => y.a.length - x.a.length);
const re = new RegExp('(?<![\\w-])(' + allAliases.map(x => x.a.replace(/[.*+?^${}()|[\\]\\\\]/g,'\\$&')).join('|') + ')(?![\\w-])', 'iu');
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node){
const p = node.parentElement;
if(!p) return NodeFilter.FILTER_REJECT;
if(p.closest('.katex, .gloss-term, button, input, select, .wg-badge, .card-icon, .sec-num, .psel-num, .hdr, .ach-popup, script, style, .search-modal, .sidecard, .gloss-tip')) return NodeFilter.FILTER_REJECT;
if(!re.test(node.nodeValue)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
const nodes = [];
let n; while((n = walker.nextNode())) nodes.push(n);
nodes.forEach(node => {
const text = node.nodeValue;
const out = document.createDocumentFragment();
let cursor = 0;
const global = new RegExp(re.source, 'giu');
let m;
while((m = global.exec(text)) !== null){
if(m.index > cursor) out.appendChild(document.createTextNode(text.slice(cursor, m.index)));
const found = m[0].toLowerCase();
const hit = allAliases.find(x => x.a.toLowerCase() === found);
const g = hit ? GLOSSARY[hit.i] : null;
const sp = document.createElement('span');
sp.className = 'gloss-term';
sp.dataset.gloss = g ? g.term : '';
sp.textContent = m[0];
out.appendChild(sp);
cursor = m.index + m[0].length;
}
if(cursor < text.length) out.appendChild(document.createTextNode(text.slice(cursor)));
node.parentNode.replaceChild(out, node);
});
root.__glossDone = true;
}
function initGlossaryTip(){
const tip = document.getElementById('gloss-tip');
if(!tip) return;
let lockOpen = null;
function show(el){
const g = GLOSSARY.find(x => x.term === el.dataset.gloss);
if(!g) return;
tip.innerHTML = '<b>' + g.term[0].toUpperCase() + g.term.slice(1) + '</b><div style="margin-top:4px">' + g.def + '</div><div style="margin-top:6px;font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em">См. §&nbsp;' + g.sec.replace('p','') + '</div>';
if(window.renderMathInElement) renderMath(tip);
const r = el.getBoundingClientRect();
tip.classList.add('show');
const tw = tip.offsetWidth, th = tip.offsetHeight;
let left = r.left, top = r.bottom + 8;
if(left + tw > window.innerWidth - 12) left = window.innerWidth - tw - 12;
if(top + th > window.innerHeight - 12) top = r.top - th - 8;
tip.style.left = Math.max(8, left) + 'px';
tip.style.top = Math.max(8, top) + 'px';
}
function hide(){ tip.classList.remove('show'); }
document.addEventListener('mouseover', e => {
const el = e.target.closest && e.target.closest('.gloss-term');
if(el && !lockOpen) show(el);
});
document.addEventListener('mouseout', e => {
const el = e.target.closest && e.target.closest('.gloss-term');
if(el && !lockOpen) hide();
});
document.addEventListener('click', e => {
const el = e.target.closest && e.target.closest('.gloss-term');
if(el){
if(lockOpen === el){ lockOpen = null; hide(); }
else { lockOpen = el; show(el); }
} else if(lockOpen && !e.target.closest('.gloss-tip')){
lockOpen = null; hide();
}
});
}
/* ============================================================
SEARCH — Ctrl+K modal across glossary + sections + formulas
============================================================ */
const SEARCH_INDEX = (function(){
const arr = [];
PARAS.forEach(p => arr.push({ kind:'Параграф', title:p.num + ' ' + p.name, desc:p.sub || '', sec:p.id }));
GLOSSARY.forEach(g => arr.push({ kind:'Понятие', title:g.term, desc:g.def.replace(/\$/g,''), sec:g.sec, gloss:g.term }));
[
['Формула','D = b² 4ac','§8 — дискриминант','p8'],
['Формула','x = (b ± √D) / 2a','§8 — корни через дискриминант','p8'],
['Формула','x₁ + x₂ = p, x₁·x₂ = q','§9 — теорема Виета','p9'],
['Формула','ax² + bx + c = a(x x₁)(x x₂)','§10 — разложение трёхчлена','p10'],
['Формула','t = x², t ≥ 0','§12 — замена для биквадратного','p12'],
].forEach(([k,t,d,s]) => arr.push({ kind:k, title:t, desc:d, sec:s }));
arr.push({ kind:'Финал', title:'Боссы главы', desc:'7 проверочных боссов', sec:'final2' });
return arr;
})();
function initSearch(){
const modal = document.getElementById('search-modal');
const inp = document.getElementById('search-input');
const out = document.getElementById('search-results');
const btn = document.getElementById('search-btn');
if(!modal || !inp || !out) return;
let cur = 0, rows = [];
function rank(q){
q = q.trim().toLowerCase();
if(!q) return SEARCH_INDEX.slice(0, 12);
return SEARCH_INDEX
.map(it => ({ it, s: score(q, it) }))
.filter(x => x.s > 0)
.sort((a,b) => b.s - a.s)
.slice(0, 20)
.map(x => x.it);
}
function score(q, it){
const t = (it.title + ' ' + it.desc).toLowerCase();
if(t.includes(q)) return 100 + (it.title.toLowerCase().startsWith(q) ? 50 : 0);
let s = 0;
q.split(/\s+/).forEach(w => { if(w && t.includes(w)) s += 10; });
return s;
}
function render(){
cur = 0;
if(!rows.length){ out.innerHTML = '<div class="search-empty">Ничего не найдено</div>'; return; }
out.innerHTML = rows.map((r, i) => `<button class="search-row${i === 0 ? ' active' : ''}" data-i="${i}"><div class="sr-kind">${r.kind}</div><div class="sr-title">${r.title}</div>${r.desc ? `<div class="sr-desc">${r.desc.length > 90 ? r.desc.slice(0, 90) + '…' : r.desc}</div>` : ''}</button>`).join('');
out.querySelectorAll('.search-row').forEach(b => b.addEventListener('click', ()=>{ cur = +b.dataset.i; pick(); }));
}
function pick(){
const r = rows[cur]; if(!r) return;
close();
goTo(r.sec);
if(r.gloss){
setTimeout(()=>{
const sec = document.getElementById('sec-' + r.sec);
const el = sec && sec.querySelector('[data-gloss="' + r.gloss + '"]');
if(el){ el.scrollIntoView({ behavior:'smooth', block:'center' }); el.style.transition = 'background .3s'; el.style.background = 'var(--warn,#f59e0b)'; setTimeout(()=>{ el.style.background = ''; }, 1400); }
}, 400);
}
}
function move(d){
const items = out.querySelectorAll('.search-row'); if(!items.length) return;
items[cur] && items[cur].classList.remove('active');
cur = (cur + d + items.length) % items.length;
items[cur].classList.add('active');
items[cur].scrollIntoView({ block:'nearest' });
}
function open(){ modal.classList.add('show'); inp.value = ''; rows = rank(''); render(); setTimeout(()=>inp.focus(), 50); }
function close(){ modal.classList.remove('show'); }
btn && btn.addEventListener('click', open);
modal.addEventListener('click', e => { if(e.target === modal) close(); });
inp.addEventListener('input', ()=>{ rows = rank(inp.value); render(); });
inp.addEventListener('keydown', e => {
if(e.key === 'ArrowDown'){ e.preventDefault(); move(1); }
else if(e.key === 'ArrowUp'){ e.preventDefault(); move(-1); }
else if(e.key === 'Enter'){ e.preventDefault(); pick(); }
else if(e.key === 'Escape'){ e.preventDefault(); close(); }
});
document.addEventListener('keydown', e => {
if((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')){
e.preventDefault();
if(modal.classList.contains('show')) close(); else open();
}
});
}
/* INIT */
function initSidebarToggle(){
const side = document.getElementById('col-side');
@@ -879,6 +1103,8 @@ function init(){
loadProgress();
initTheme();
initSidebarToggle();
initGlossaryTip();
initSearch();
buildParaSelector();
refreshProgressUI();
goTo('p7');