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:
+195
-2
@@ -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')">В строке \( … \)</button>
|
||||
<button class="fx-mode-btn" id="fx-mode-block" onclick="fxSetMode('block')">Блоком \[ … \]</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;
|
||||
|
||||
Reference in New Issue
Block a user