feat(flashcards): ввод формул KaTeX в редакторе (палитра + превью)

Перенесён подход из редактора теории:
- модалка «Вставить формулу»: палитра символов по категориям
  (греческие/операции/степени/отношения/стрелки/скобки/физика),
  LaTeX-поле, живое KaTeX-превью, режим «в строке \( \)» / «блоком \[ \]»
- кнопка «ƒₓ» у каждой стороны карточки и в add-bar; вставка в активное поле
- палитра на data-tex + делегирование (inline-onclick схлопывал «\» в латехе)
- Ctrl+Enter в поле формулы = вставить; разделители совпадают с рендером изучения

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-02 13:25:02 +03:00
parent 51e5dc29e1
commit 751d88048c
+195 -2
View File
@@ -177,6 +177,37 @@
.bulk-img-thumb { max-width: 110px; max-height: 64px; border-radius: 6px; display: block;
border: 1px solid var(--border); object-fit: cover; }
/* ── formula insert (KaTeX) ── */
.card-side-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px; }
.card-side-head .card-side-lbl { margin-bottom: 0; }
.fx-mini { background: none; border: none; cursor: pointer; color: var(--violet); font-weight: 700;
font-family: 'Times New Roman', serif; font-style: italic; font-size: .92rem; line-height: 1;
padding: 1px 5px; border-radius: 6px; opacity: .7; transition: .15s; }
.fx-mini:hover { opacity: 1; background: rgba(155,93,229,.08); }
.fx-mode-row { display: flex; gap: 8px; margin-bottom: 12px; }
.fx-mode-btn { flex: 1; padding: 8px; border: 1.5px solid var(--border); border-radius: 9px; background: #fff;
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .76rem; font-weight: 700;
color: var(--text-2); transition: .15s; }
.fx-mode-btn.active { border-color: var(--violet); background: rgba(155,93,229,.08); color: var(--violet); }
.fx-cats { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.fx-cat-btn { padding: 4px 11px; border: 1px solid var(--border); border-radius: 20px; background: #fff;
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 600;
color: var(--text-2); transition: .15s; }
.fx-cat-btn.active { background: var(--violet); color: #fff; border-color: var(--violet); }
.fx-palette { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; max-height: 132px; overflow-y: auto; }
.fx-sym { min-width: 34px; height: 32px; padding: 0 8px; border: 1px solid var(--border); border-radius: 8px;
background: #fff; cursor: pointer; font-size: .98rem; color: var(--text);
display: inline-flex; align-items: center; justify-content: center; transition: .12s; }
.fx-sym:hover { background: rgba(155,93,229,.1); border-color: var(--violet); }
.fx-sym .ic { width: 15px; height: 15px; }
#fx-input { width: 100%; box-sizing: border-box; font-family: 'Courier New', monospace; }
.fx-preview-label { font-size: .7rem; font-weight: 700; color: var(--text-3); text-transform: uppercase;
letter-spacing: .05em; margin: 12px 0 6px; }
.fx-preview { min-height: 50px; padding: 14px; border: 1.5px dashed var(--border); border-radius: 10px;
background: var(--surface-2); display: flex; align-items: center; justify-content: center;
font-size: 1.15rem; overflow-x: auto; }
.fx-ph { color: var(--text-3); font-size: .82rem; font-style: italic; }
.card-add-bar { display: flex; gap: 10px; align-items: center; }
.card-add-input { flex: 1; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: .88rem; background: #fff;
@@ -382,6 +413,7 @@
onkeydown="addCardOnEnter(event)" onpaste="onNewCardPaste(event,'front')" />
<input class="card-add-input" id="new-card-back" placeholder="Обратная сторона (ответ)…"
onkeydown="addCardOnEnter(event)" onpaste="onNewCardPaste(event,'back')" />
<button class="fc-btn fc-btn-ghost" title="Вставить формулу (KaTeX)" onclick="openFormula()">ƒₓ Формула</button>
<button class="fc-btn fc-btn-primary" onclick="addCard()">+ Добавить</button>
</div>
<div id="new-card-imgs"></div>
@@ -504,6 +536,28 @@
</div>
</div>
<!-- ── Formula (KaTeX) Modal ── -->
<div class="fc-modal" id="modal-formula">
<div class="fc-modal-bg" onclick="closeModal('modal-formula')"></div>
<div class="fc-modal-box" style="max-width:560px">
<div class="fc-modal-title">Вставить формулу (KaTeX)</div>
<div class="fx-mode-row">
<button class="fx-mode-btn active" id="fx-mode-inline" onclick="fxSetMode('inline')">В строке&nbsp; \( … \)</button>
<button class="fx-mode-btn" id="fx-mode-block" onclick="fxSetMode('block')">Блоком&nbsp; \[ … \]</button>
</div>
<div class="fx-cats" id="fx-cats"></div>
<div class="fx-palette" id="fx-palette"></div>
<textarea class="fc-modal-input" id="fx-input" rows="2"
placeholder="\frac{q_1 q_2}{r^2}" oninput="updateFxPreview()"></textarea>
<div class="fx-preview-label">Превью</div>
<div class="fx-preview" id="fx-preview"></div>
<div class="fc-modal-actions">
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-formula')">Отмена</button>
<button class="fc-btn fc-btn-primary" onclick="fxInsert()">Вставить</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
@@ -552,6 +606,7 @@ let _newImg = { front: '', back: '' }; // картинки, прикреплё
async function init() {
buildColorPicker();
bindStudyKeys();
bindFormulaUI();
await loadDecks();
}
@@ -741,7 +796,10 @@ function renderCardList() {
<svg class="ic" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></svg>
</div>`}
<div class="card-side">
<div class="card-side-lbl">Вопрос</div>
<div class="card-side-head">
<span class="card-side-lbl">Вопрос</span>
<button class="fx-mini" title="Вставить формулу (KaTeX)" onclick="openFormula(this)">ƒₓ</button>
</div>
<textarea class="card-textarea" rows="2"
onpaste="onCardPaste(event,${c.id},'front')"
onchange="saveCard(${c.id},'front',this.value)">${esc(c.front)}</textarea>
@@ -749,7 +807,10 @@ function renderCardList() {
</div>
<div class="card-divider"></div>
<div class="card-side">
<div class="card-side-lbl">Ответ</div>
<div class="card-side-head">
<span class="card-side-lbl">Ответ</span>
<button class="fx-mini" title="Вставить формулу (KaTeX)" onclick="openFormula(this)">ƒₓ</button>
</div>
<textarea class="card-textarea" rows="2"
onpaste="onCardPaste(event,${c.id},'back')"
onchange="saveCard(${c.id},'back',this.value)">${esc(c.back)}</textarea>
@@ -1123,6 +1184,138 @@ async function saveBulk() {
closeModal('modal-bulk');
}
/* ════ Formula insert (KaTeX) ════
Палитра символов перенесена из редактора теории (lesson-editor.html).
Текст карточки свободный — вставляем \( … \) (в строке) или \[ … \] (блоком)
в активное поле; в режиме изучения KaTeX уже рендерит эти разделители. */
const FX_SYMS = {
'Греческие': [
['\\alpha','α'],['\\beta','β'],['\\gamma','γ'],['\\delta','δ'],['\\epsilon','ε'],
['\\zeta','ζ'],['\\eta','η'],['\\theta','θ'],['\\lambda','λ'],['\\mu','μ'],
['\\nu','ν'],['\\xi','ξ'],['\\pi','π'],['\\rho','ρ'],['\\sigma','σ'],
['\\tau','τ'],['\\phi','φ'],['\\chi','χ'],['\\psi','ψ'],['\\omega','ω'],
['\\Gamma','Γ'],['\\Delta','Δ'],['\\Theta','Θ'],['\\Lambda','Λ'],['\\Pi','Π'],
['\\Sigma','Σ'],['\\Phi','Φ'],['\\Psi','Ψ'],['\\Omega','Ω'],
],
'Операции': [
['\\frac{a}{b}','a/b'],['\\sqrt{x}','√'],['\\sqrt[n]{x}','ⁿ√'],
['\\sum','∑'],['\\prod','∏'],['\\int','∫'],['\\oint','∮'],
['\\lim','lim'],['\\infty','∞'],['\\partial','∂'],['\\nabla','∇'],
['\\pm','±'],['\\mp','∓'],['\\times','×'],['\\div','÷'],['\\cdot','·'],
],
'Степени': [
['^{2}','x²'],['^{3}','x³'],['_{n}','xₙ'],['_{i}','xᵢ'],
['e^{x}','eˣ'],['10^{n}','10ⁿ'],
],
'Отношения': [
['\\leq','≤'],['\\geq','≥'],['\\neq','≠'],['\\approx','≈'],['\\equiv','≡'],
['\\sim',''],['\\propto','∝'],['\\ll','≪'],['\\gg','≫'],
],
'Стрелки': [
['\\to','→'],['\\leftarrow','←'],['\\Rightarrow','⇒'],['\\Leftrightarrow','⇔'],
['\\uparrow','↑'],['\\downarrow','↓'],
],
'Скобки': [
['\\left( \\right)','(…)'],['\\left[ \\right]','[…]'],['\\left\\{ \\right\\}','{…}'],
['\\left| \\right|','|…|'],
],
'Физика': [
['\\vec{F}','F⃗'],['\\hat{x}','x̂'],['\\hbar','ℏ'],['\\Delta t','Δt'],
['\\mathbf{E}','E'],['\\mathbf{B}','B'],
],
};
let _fxField = null; // целевое поле для вставки (textarea/input)
let _fxMode = 'inline';
let _fxCat = 'Греческие';
function bindFormulaUI() {
// запоминаем последнее сфокусированное поле редактора карточек
document.addEventListener('focusin', (e) => {
const t = e.target;
if (t && ((t.classList && t.classList.contains('card-textarea')) ||
t.id === 'new-card-front' || t.id === 'new-card-back')) {
_fxField = t;
}
});
// делегирование на палитру/категории (без inline-onclick — латех с «\» не ломается)
document.getElementById('fx-cats')?.addEventListener('click', (e) => {
const b = e.target.closest('.fx-cat-btn'); if (b) fxSetCat(b.dataset.cat, b);
});
document.getElementById('fx-palette')?.addEventListener('click', (e) => {
const b = e.target.closest('.fx-sym'); if (b) fxInsertSym(b.dataset.tex || '');
});
document.getElementById('fx-input')?.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); fxInsert(); }
});
}
function openFormula(btn) {
if (btn) { const ta = btn.closest('.card-side')?.querySelector('textarea'); if (ta) _fxField = ta; }
if (!_fxField || !document.body.contains(_fxField)) _fxField = document.getElementById('new-card-front');
document.getElementById('fx-input').value = '';
_fxBuildCats();
_fxBuildPalette();
updateFxPreview();
document.getElementById('modal-formula').classList.add('open');
setTimeout(() => document.getElementById('fx-input').focus(), 50);
}
function fxSetMode(m) {
_fxMode = m;
document.getElementById('fx-mode-inline').classList.toggle('active', m === 'inline');
document.getElementById('fx-mode-block').classList.toggle('active', m === 'block');
updateFxPreview();
}
function _fxBuildCats() {
document.getElementById('fx-cats').innerHTML = Object.keys(FX_SYMS).map(c =>
`<button class="fx-cat-btn${c === _fxCat ? ' active' : ''}" type="button" data-cat="${esc(c)}">${esc(c)}</button>`
).join('');
}
function fxSetCat(cat, btn) {
_fxCat = cat;
document.querySelectorAll('#fx-cats .fx-cat-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
_fxBuildPalette();
}
function _fxBuildPalette() {
document.getElementById('fx-palette').innerHTML = (FX_SYMS[_fxCat] || []).map(([latex, disp]) =>
`<button class="fx-sym" type="button" title="${esc(latex)}" data-tex="${esc(latex)}">${disp}</button>`
).join('');
}
function fxInsertSym(latex) {
const ta = document.getElementById('fx-input');
const s = ta.selectionStart ?? ta.value.length, e = ta.selectionEnd ?? ta.value.length;
ta.value = ta.value.slice(0, s) + latex + ta.value.slice(e);
ta.selectionStart = ta.selectionEnd = s + latex.length;
ta.focus();
updateFxPreview();
}
function updateFxPreview() {
const latex = document.getElementById('fx-input').value;
const pv = document.getElementById('fx-preview');
if (!latex.trim()) { pv.innerHTML = '<span class="fx-ph">Превью формулы появится здесь</span>'; return; }
const wrapped = _fxMode === 'block' ? `\\[${latex}\\]` : `\\(${latex}\\)`;
pv.innerHTML = mathHtmlFC(wrapped);
}
function fxInsert() {
const latex = document.getElementById('fx-input').value.trim();
if (!latex) { closeModal('modal-formula'); return; }
const wrapped = _fxMode === 'block' ? `\\[ ${latex} \\]` : `\\( ${latex} \\)`;
const ta = _fxField;
if (ta) {
const s = ta.selectionStart ?? ta.value.length, e = ta.selectionEnd ?? ta.value.length;
ta.value = ta.value.slice(0, s) + wrapped + ta.value.slice(e);
ta.selectionStart = ta.selectionEnd = s + wrapped.length;
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
ta.focus();
}
closeModal('modal-formula');
}
/* ════ Study mode ════ */
let _studyCards = [];
let _studyIdx = 0;