'use strict'; /* ────────────────────────────────────────────────────────────────── TaskCard component — reusable across variants / practice / topic / mock views. Renders ONE task with answer input, check button, and solution toggle. Public API: EP.TaskCard.render(container, task, opts) container : HTMLElement task : { id, idx, type, text, figure, opts, answer, solution } opts : { mode : 'variant'|'practice'|'topic'|'mock' sessionId : number | null (groups attempts in a session) autoCheck : true (whether check button + verdict UI is shown) showSolution : true (whether solution toggle exists) readonly : false (inputs disabled; for finished mock review) prefillAnswer : string | null (initial user_answer; for mock review) forceVerdict : {isCorrect:0|1} | null (show verdict badge as if checked) onAnswerChange : (taskId, value) => void (per-keystroke, debounced; mock auto-save) onAttempt : (result) => void (notify parent after check or solution-view) numbering : number | null (override task number badge) } ────────────────────────────────────────────────────────────────── */ (function () { const ICONS = { chev: '', check: '', cross: '', rotate: '', }; function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); } /* HTML → readable plain text (keeps $…$ math source) for saving to materials. */ function stripHtml(s) { return String(s || '').replace(//gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } /* topic_ref → "Учить тему" deep-link to the textbook chapter/paragraph. ref = { slug, paragraph, title }. Paragraph is null for hub links. */ function buildRefLink(ref) { if (!ref || !ref.slug) return ''; const href = ref.paragraph ? `/textbook/${encodeURIComponent(ref.slug)}#sec-p${ref.paragraph}` : `/textbook/${encodeURIComponent(ref.slug)}`; const label = ref.paragraph ? `§${ref.paragraph}` : 'учебник'; const title = ref.title ? `Учить тему: ${escapeHtml(ref.title)}` : 'Перейти к материалу'; return ` Учить тему · ${escapeHtml(label)} `; } function buildOptsBlock(taskId, opts) { const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$')); const cls = isLong ? 'tc-opts tc-opts-vertical' : 'tc-opts'; const name = `tc-opt-${taskId}`; return `
` + opts.map(([l, t]) => ` `).join('') + `
`; } /* Render one task into `container`. Returns a controller object with .destroy(). */ function render(container, task, opts = {}) { const mode = opts.mode || 'variant'; const autoCheck = opts.autoCheck !== false; const showSol = opts.showSolution !== false; const readonly = !!opts.readonly; const numbering = (opts.numbering != null) ? opts.numbering : task.idx; const sessionId = opts.sessionId || null; const onAttempt = opts.onAttempt || (() => {}); const onAnswerChange = opts.onAnswerChange || null; const card = document.createElement('div'); card.className = 'tc-card'; card.dataset.taskId = String(task.id); card.dataset.taskType = task.type; // ── Build input area by task type × autoCheck combo let inputBlock = ''; const verdictSlot = ``; const checkBtnRow = autoCheck ? `
${verdictSlot}
` : verdictSlot; if (task.type === 'mc' && task.opts) { inputBlock = buildOptsBlock(task.id, task.opts) + checkBtnRow; } else if (task.type === 'open') { inputBlock = `
` + checkBtnRow; } else if (task.type === 'long' && autoCheck) { inputBlock = `
Развёрнутый ответ — проверьте себя по решению, затем отметьте:
`; } else if (task.type === 'long' && !autoCheck) { // Mock + long: short free-text answer inputBlock = `
` + verdictSlot; } const refLink = buildRefLink(task.topic_ref); const allowSave = mode !== 'mock'; const saveMatBtn = allowSave ? `` : ''; const solToggle = (showSol && task.solution) ? `` : ''; const solPanelHtml = (showSol && task.solution) ? `
${task.solution}
` : ''; const solBlock = (solToggle || refLink || saveMatBtn) ? `
${solToggle}${refLink}${saveMatBtn}
${solPanelHtml}
` : ''; card.innerHTML = `
${numbering}
Задание ${numbering}
${typeLabel(task.type)}
${task.text}
${task.figure ? `
${task.figure}
` : ''} ${inputBlock}
${solBlock} `; container.appendChild(card); // ── KaTeX render EP.katex?.run(card); // ── Save task to «Мои материалы» (universal buffer) const saveMatEl = card.querySelector('[data-tc-savemat]'); if (saveMatEl) { saveMatEl.addEventListener('click', () => { if (!window.MaterialSave) return; const examTitle = (window.EP && EP.info && EP.info.track && EP.info.track.title) || 'Экзамен'; const loc = (task.variant != null ? 'Вариант ' + task.variant + ', ' : '') + '№' + (task.idx != null ? task.idx : numbering); const parts = [stripHtml(task.text)]; if (task.answer) parts.push('Ответ: ' + task.answer); if (task.solution) parts.push('Решение: ' + stripHtml(task.solution)); MaterialSave.note({ title: examTitle + ' · ' + loc, body: parts.join('\n\n'), sourceTitle: examTitle }, saveMatEl); }); } // ── State let startedAt = Date.now(); let solutionLogged = false; let solutionRendered = false; let attemptCount = 0; // how many CHECK attempts made let firstAttemptCorrect = null; // we report this in onAttempt // ── Prefill answer (mock review or resumed mock session) if (opts.prefillAnswer != null) { const text = card.querySelector('[data-tc-text]'); if (text) text.value = String(opts.prefillAnswer); const radios = card.querySelectorAll('input[type="radio"]'); radios.forEach(r => { if (r.value === String(opts.prefillAnswer)) r.checked = true; }); } // ── Force verdict (mock review: server has graded; show result without check button) if (opts.forceVerdict && opts.forceVerdict.isCorrect != null) { applyVerdictReadonly(opts.forceVerdict.isCorrect); } // ── Readonly: disable all inputs (mock review) if (readonly) { card.querySelectorAll('input').forEach(el => el.disabled = true); card.dataset.tcLocked = '1'; } // ── Input enable on first interaction + auto-save (mock) const inputs = card.querySelectorAll('input[type="radio"], [data-tc-text]'); const checkBtn = card.querySelector('[data-tc-check]'); let saveDebounce = null; inputs.forEach(inp => { inp.addEventListener('input', () => { updateCheckEnabled(); maybeAutoSave(); }); inp.addEventListener('change', () => { updateCheckEnabled(); maybeAutoSave(); }); }); function updateCheckEnabled() { if (!checkBtn) return; const has = readUserAnswer() !== null; checkBtn.disabled = !has || card.dataset.tcLocked === '1'; } function maybeAutoSave() { if (!onAnswerChange || readonly) return; const v = readUserAnswer(); // debounce per-keystroke text input; radio changes save immediately clearTimeout(saveDebounce); const delay = card.querySelector('input[type="radio"]:checked') ? 0 : 450; saveDebounce = setTimeout(() => onAnswerChange(task.id, v), delay); } function applyVerdictReadonly(isCorrect) { card.classList.add(isCorrect ? 'tc-correct' : 'tc-wrong'); const verdict = card.querySelector('[data-tc-verdict]'); if (verdict) { verdict.hidden = false; verdict.innerHTML = isCorrect ? `${ICONS.check} Правильно` : `${ICONS.cross} Неправильно`; } } function readUserAnswer() { const mcGroup = card.querySelector('[data-tc-mc]'); if (mcGroup) { const picked = mcGroup.querySelector('input[type="radio"]:checked'); return picked ? picked.value : null; } const text = card.querySelector('[data-tc-text]'); if (text) { const v = text.value.trim(); return v || null; } return null; } // ── Check action if (checkBtn) { checkBtn.addEventListener('click', () => { const userAnswer = readUserAnswer(); if (userAnswer == null) return; const isCorrect = EP.answer.check(userAnswer, task.answer) ? 1 : 0; attemptCount++; if (firstAttemptCorrect === null) firstAttemptCorrect = isCorrect; applyVerdict(isCorrect, userAnswer); sendAttempt({ user_answer: userAnswer, is_correct: isCorrect }); onAttempt({ taskId: task.id, isCorrect, userAnswer, attempt: attemptCount }); }); } function applyVerdict(isCorrect, userAnswer) { const verdict = card.querySelector('[data-tc-verdict]'); card.classList.remove('tc-correct', 'tc-wrong'); card.classList.add(isCorrect ? 'tc-correct' : 'tc-wrong'); if (verdict) { verdict.hidden = false; verdict.innerHTML = isCorrect ? `${ICONS.check} Правильно` : `${ICONS.cross} Неправильно `; } if (isCorrect) { // Lock inputs + auto-open solution card.querySelectorAll('input').forEach(el => el.disabled = true); checkBtn.disabled = true; card.dataset.tcLocked = '1'; autoOpenSolution(); } else { // Allow retry const retry = card.querySelector('[data-tc-retry]'); if (retry) retry.addEventListener('click', () => resetForRetry()); } } function resetForRetry() { const verdict = card.querySelector('[data-tc-verdict]'); card.classList.remove('tc-wrong'); if (verdict) { verdict.hidden = true; verdict.innerHTML = ''; } startedAt = Date.now(); } function autoOpenSolution() { const btn = card.querySelector('[data-tc-sol]'); const panel = card.querySelector('[data-tc-sol-panel]'); if (btn && panel && !panel.classList.contains('visible')) { toggleSolution(btn, panel); } } // ── Solution toggle (manual) const solBtn = card.querySelector('[data-tc-sol]'); const solPanel = card.querySelector('[data-tc-sol-panel]'); if (solBtn && solPanel) { solBtn.addEventListener('click', () => toggleSolution(solBtn, solPanel)); } function toggleSolution(btn, panel) { const open = panel.classList.contains('visible'); panel.classList.toggle('visible', !open); btn.classList.toggle('open', !open); btn.querySelector('span').textContent = open ? 'Показать решение' : 'Скрыть решение'; if (!open) { if (!solutionRendered) { EP.katex?.run(panel); solutionRendered = true; } if (!solutionLogged) { solutionLogged = true; sendAttempt({ solution_viewed: 1 }); onAttempt({ taskId: task.id, solutionViewed: true }); } } } // ── Long: self-mark buttons card.querySelectorAll('[data-tc-self]').forEach(btn => { btn.addEventListener('click', () => { const val = btn.dataset.tcSelf === '1' ? 1 : 0; attemptCount++; if (firstAttemptCorrect === null) firstAttemptCorrect = val; card.classList.remove('tc-correct', 'tc-wrong'); card.classList.add(val ? 'tc-correct' : 'tc-wrong'); card.querySelectorAll('[data-tc-self]').forEach(b => b.disabled = true); sendAttempt({ is_correct: val }); onAttempt({ taskId: task.id, isCorrect: val, attempt: attemptCount }); }); }); function sendAttempt(partial) { const body = Object.assign({ exam_task_id: task.id, mode, session_id: sessionId, time_ms: Math.min(Date.now() - startedAt, 24 * 3600 * 1000), user_answer: null, is_correct: null, hint_used: 0, solution_viewed:0, }, partial); // Best-effort — UI doesn't block on this EP.api.saveAttempt(body).catch(() => {}); } // ── Review mode: auto-open the solution panel for instant context if (opts.forceVerdict && showSol) { const sb = card.querySelector('[data-tc-sol]'); const sp = card.querySelector('[data-tc-sol-panel]'); if (sb && sp && !sp.classList.contains('visible')) { sp.classList.add('visible'); sb.classList.add('open'); sb.querySelector('span').textContent = 'Скрыть решение'; EP.katex?.run(sp); } } return { el: card, destroy: () => card.remove(), }; } function typeLabel(type) { if (type === 'mc') return 'выбор'; if (type === 'open') return 'кр. ответ'; return 'развёрнут.'; } window.EP = window.EP || {}; window.EP.TaskCard = { render }; })();