feat(reader): implement FEAT-01 Focus Zen Mode (100% vh/vw, auto-hiding HUD, hotkeys F/Esc, native fullscreen)
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
// =======================================================================
|
||||
|
||||
import { api } from './api.js';
|
||||
import { showToast, escapeHtml } from './ui.js';
|
||||
import { showToast, escapeHtml, renderMarkdownTable, renderMarkdownNotes } from './ui.js';
|
||||
|
||||
let allUnits = [];
|
||||
let currentFilter = 'all';
|
||||
@@ -29,6 +29,18 @@ export function initGrammar() {
|
||||
const sumUnitsBtn = document.getElementById('grammar-sum-units-btn');
|
||||
const sumSrsBtn = document.getElementById('grammar-sum-srs-btn');
|
||||
const handwrittenToggle = document.getElementById('grammar-handwritten-toggle');
|
||||
const cheatsheetToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
|
||||
const cheatsheetDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
|
||||
|
||||
if (cheatsheetToggle && cheatsheetDrawer) {
|
||||
cheatsheetToggle.addEventListener('click', () => {
|
||||
const isNowHidden = !cheatsheetDrawer.classList.contains('hidden');
|
||||
cheatsheetDrawer.classList.toggle('hidden', isNowHidden);
|
||||
cheatsheetToggle.innerHTML = isNowHidden
|
||||
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
|
||||
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
|
||||
});
|
||||
}
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
@@ -314,6 +326,50 @@ function renderUnitTheory() {
|
||||
handwrittenToggle.checked = Boolean(currentUnit.handwritten_logged);
|
||||
}
|
||||
|
||||
// Cheatsheet Markdown Table
|
||||
const cheatsheetSec = document.getElementById('grammar-cheatsheet-section');
|
||||
const cheatsheetBox = document.getElementById('grammar-cheatsheet-content');
|
||||
if (cheatsheetSec && cheatsheetBox) {
|
||||
if (currentUnit.cheatsheet_table && currentUnit.cheatsheet_table.trim()) {
|
||||
cheatsheetBox.innerHTML = renderMarkdownTable(currentUnit.cheatsheet_table);
|
||||
cheatsheetSec.classList.remove('hidden');
|
||||
} else {
|
||||
cheatsheetBox.innerHTML = '';
|
||||
cheatsheetSec.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Detailed Murphy Notes
|
||||
const notesSec = document.getElementById('grammar-detailed-notes-section');
|
||||
const notesBox = document.getElementById('grammar-detailed-notes-content');
|
||||
if (notesSec && notesBox) {
|
||||
if (currentUnit.detailed_notes && currentUnit.detailed_notes.trim()) {
|
||||
notesBox.innerHTML = renderMarkdownNotes(currentUnit.detailed_notes);
|
||||
notesSec.classList.remove('hidden');
|
||||
} else {
|
||||
notesBox.innerHTML = '';
|
||||
notesSec.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Practice Cheatsheet Drawer content
|
||||
const practiceDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
|
||||
const practiceToggleWrap = document.querySelector('.practice-cheatsheet-toggle-wrap');
|
||||
if (practiceDrawer && practiceToggleWrap) {
|
||||
if (currentUnit.cheatsheet_table && currentUnit.cheatsheet_table.trim()) {
|
||||
practiceDrawer.innerHTML = renderMarkdownTable(currentUnit.cheatsheet_table);
|
||||
practiceToggleWrap.classList.remove('hidden');
|
||||
} else {
|
||||
practiceDrawer.innerHTML = '';
|
||||
practiceToggleWrap.classList.add('hidden');
|
||||
}
|
||||
practiceDrawer.classList.add('hidden');
|
||||
const practiceToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
|
||||
if (practiceToggle) {
|
||||
practiceToggle.innerHTML = '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
// Golden formula
|
||||
const formulaEl = document.getElementById('grammar-golden-formula-text');
|
||||
if (formulaEl) {
|
||||
@@ -368,6 +424,12 @@ function startUnitPractice() {
|
||||
sessionCorrect = 0;
|
||||
sessionIngestedCards = [];
|
||||
|
||||
// Ensure cheatsheet drawer is collapsed at practice start
|
||||
const practiceDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
|
||||
const practiceToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
|
||||
if (practiceDrawer) practiceDrawer.classList.add('hidden');
|
||||
if (practiceToggle) practiceToggle.innerHTML = '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>';
|
||||
|
||||
renderExercise();
|
||||
}
|
||||
|
||||
|
||||
+103
-20
@@ -4,7 +4,7 @@
|
||||
// =======================================================================
|
||||
|
||||
import { api } from './api.js';
|
||||
import { ui, escapeHtml, showToast, switchView, shuffleArray, escapeRegex, playAudio, playTextSpeech, cleanPromptTranslation } from './ui.js';
|
||||
import { ui, escapeHtml, showToast, switchView, shuffleArray, escapeRegex, playAudio, playTextSpeech, cleanPromptTranslation, renderMarkdownTable, renderMarkdownNotes } from './ui.js';
|
||||
|
||||
let presets = [];
|
||||
let currentPreset = null;
|
||||
@@ -1332,6 +1332,18 @@ async function runGrammarReviewStep(step, container) {
|
||||
<span class="badge-formula">${escapeHtml(unitDetail.golden_formula || '')}</span>
|
||||
</div>
|
||||
|
||||
${unitDetail.cheatsheet_table ? `
|
||||
<div class="practice-cheatsheet-toggle-wrap mt-2">
|
||||
<button type="button" id="review-cheatsheet-toggle-btn" class="btn btn-sm btn-ghost practice-cheatsheet-btn">
|
||||
<span>📋 Шпаргалка по теме</span>
|
||||
<i class="ph-caret-down"></i>
|
||||
</button>
|
||||
<div id="review-cheatsheet-drawer" class="practice-cheatsheet-drawer hidden">
|
||||
${renderMarkdownTable(unitDetail.cheatsheet_table)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="grammar-question-box mt-3">
|
||||
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
|
||||
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
|
||||
@@ -1350,6 +1362,18 @@ async function runGrammarReviewStep(step, container) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const reviewCsBtn = document.getElementById('review-cheatsheet-toggle-btn');
|
||||
const reviewCsDrawer = document.getElementById('review-cheatsheet-drawer');
|
||||
if (reviewCsBtn && reviewCsDrawer) {
|
||||
reviewCsBtn.addEventListener('click', () => {
|
||||
const isHidden = reviewCsDrawer.classList.contains('hidden');
|
||||
reviewCsDrawer.classList.toggle('hidden', !isHidden);
|
||||
reviewCsBtn.innerHTML = !isHidden
|
||||
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
|
||||
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
|
||||
});
|
||||
}
|
||||
|
||||
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
// Disable all options
|
||||
@@ -1432,7 +1456,11 @@ async function runGrammarNewStep(step, container) {
|
||||
let stage = 'theory'; // theory -> exercises
|
||||
|
||||
function renderTheory() {
|
||||
const contrasts = unitDetail.contrast_explanation || [];
|
||||
const contrasts = unitDetail.contrasts || unitDetail.contrast_explanation || [];
|
||||
const hasCheatsheet = Boolean(unitDetail.cheatsheet_table && unitDetail.cheatsheet_table.trim());
|
||||
const hasNotes = Boolean(unitDetail.detailed_notes && unitDetail.detailed_notes.trim());
|
||||
const hasPrompt = Boolean(unitDetail.journaling_prompt && unitDetail.journaling_prompt.trim());
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="step-card-box">
|
||||
<div class="step-mini-header">
|
||||
@@ -1449,29 +1477,60 @@ async function runGrammarNewStep(step, container) {
|
||||
<pre class="formula-code">${escapeHtml(unitDetail.golden_formula)}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Contrast Situations -->
|
||||
${contrasts.map(c => `
|
||||
<div class="contrast-card mt-3">
|
||||
<div class="contrast-row">
|
||||
<div class="contrast-col a">
|
||||
<div class="col-title">${escapeHtml(c.situation_a)}</div>
|
||||
<div class="col-example">${escapeHtml(c.example_a)}</div>
|
||||
<div class="col-trans">${escapeHtml(c.trans_a)}</div>
|
||||
</div>
|
||||
<div class="contrast-col b">
|
||||
<div class="col-title">${escapeHtml(c.situation_b)}</div>
|
||||
<div class="col-example">${escapeHtml(c.example_b)}</div>
|
||||
<div class="col-trans">${escapeHtml(c.trans_b)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contrast-diff mt-2">💡 <strong>Ключевая разница:</strong> ${escapeHtml(c.key_difference)}</div>
|
||||
<!-- Cheatsheet Markdown Table -->
|
||||
${hasCheatsheet ? `
|
||||
<div class="lesson-grammar-section mt-4">
|
||||
<div class="section-title"><span class="title-icon">📊</span> Наглядная шпаргалка и правила (Таблица):</div>
|
||||
${renderMarkdownTable(unitDetail.cheatsheet_table)}
|
||||
</div>
|
||||
`).join('')}
|
||||
` : ''}
|
||||
|
||||
<!-- Detailed Notes -->
|
||||
${hasNotes ? `
|
||||
<div class="lesson-grammar-section mt-4">
|
||||
<div class="section-title"><span class="title-icon">📖</span> Подробный конспект темы (Raymond Murphy):</div>
|
||||
${renderMarkdownNotes(unitDetail.detailed_notes)}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Contrast Situations -->
|
||||
${contrasts.length > 0 ? `
|
||||
<div class="lesson-grammar-section mt-4">
|
||||
<div class="section-title"><span class="title-icon">⚖️</span> Метод контрастов (В чём суть и когда применять):</div>
|
||||
${contrasts.map(c => `
|
||||
<div class="contrast-card mt-2">
|
||||
<div class="contrast-row">
|
||||
<div class="contrast-col a">
|
||||
<div class="col-title">${escapeHtml(c.situation_a)}</div>
|
||||
<div class="col-example">${escapeHtml(c.example_a)}</div>
|
||||
<div class="col-trans">${escapeHtml(c.trans_a)}</div>
|
||||
</div>
|
||||
<div class="contrast-col b">
|
||||
<div class="col-title">${escapeHtml(c.situation_b)}</div>
|
||||
<div class="col-example">${escapeHtml(c.example_b)}</div>
|
||||
<div class="col-trans">${escapeHtml(c.trans_b)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contrast-diff mt-2">💡 <strong>Ключевая разница:</strong> ${escapeHtml(c.key_difference)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Handwritten Journaling Prompt -->
|
||||
${hasPrompt ? `
|
||||
<div class="grammar-journaling-card mt-4">
|
||||
<div class="journaling-prompt-box">
|
||||
<div class="formula-label">📓 Персональный дневник от руки (Motor Memory):</div>
|
||||
<p class="journaling-prompt-text mt-1">${escapeHtml(unitDetail.journaling_prompt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Handwritten Journaling Checkbox -->
|
||||
<div class="journaling-box mt-4">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" id="hw-checkbox">
|
||||
<input type="checkbox" id="hw-checkbox" ${unitDetail.handwritten_logged ? 'checked' : ''}>
|
||||
<span class="checkmark"></span>
|
||||
<span class="hw-text">✍️ Я записал формулу и пример в бумажную тетрадь</span>
|
||||
</label>
|
||||
@@ -1541,6 +1600,18 @@ async function runGrammarNewStep(step, container) {
|
||||
<span class="badge">Практика темы (${exIdx + 1} из ${selectedExercises.length})</span>
|
||||
</div>
|
||||
|
||||
${unitDetail.cheatsheet_table ? `
|
||||
<div class="practice-cheatsheet-toggle-wrap mt-2">
|
||||
<button type="button" id="lesson-cheatsheet-toggle-btn" class="btn btn-sm btn-ghost practice-cheatsheet-btn">
|
||||
<span>📋 Шпаргалка по теме</span>
|
||||
<i class="ph-caret-down"></i>
|
||||
</button>
|
||||
<div id="lesson-cheatsheet-drawer" class="practice-cheatsheet-drawer hidden">
|
||||
${renderMarkdownTable(unitDetail.cheatsheet_table)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="grammar-question-box mt-3">
|
||||
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
|
||||
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
|
||||
@@ -1559,6 +1630,18 @@ async function runGrammarNewStep(step, container) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const lessonCsBtn = document.getElementById('lesson-cheatsheet-toggle-btn');
|
||||
const lessonCsDrawer = document.getElementById('lesson-cheatsheet-drawer');
|
||||
if (lessonCsBtn && lessonCsDrawer) {
|
||||
lessonCsBtn.addEventListener('click', () => {
|
||||
const isHidden = lessonCsDrawer.classList.contains('hidden');
|
||||
lessonCsDrawer.classList.toggle('hidden', !isHidden);
|
||||
lessonCsBtn.innerHTML = !isHidden
|
||||
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
|
||||
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
|
||||
});
|
||||
}
|
||||
|
||||
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
container.querySelectorAll('.grammar-opt-btn').forEach(b => b.disabled = true);
|
||||
|
||||
@@ -28,6 +28,13 @@ let popupCurrentData = null;
|
||||
let readerScrollSaveTimer = null;
|
||||
let popupScrollRaf = null;
|
||||
|
||||
let isZenMode = false;
|
||||
let zenHudIdleTimer = null;
|
||||
|
||||
export function getIsZenMode() {
|
||||
return isZenMode;
|
||||
}
|
||||
|
||||
export function getCurrentBook() {
|
||||
return currentBook;
|
||||
}
|
||||
@@ -134,6 +141,7 @@ function createBookCard(b) {
|
||||
}
|
||||
|
||||
export async function loadBookshelf() {
|
||||
if (isZenMode) toggleZenMode(false);
|
||||
const bookshelfContainer = document.getElementById('bookshelf-container');
|
||||
const readerInterface = document.getElementById('reader-interface');
|
||||
const bookshelfGrid = document.getElementById('bookshelf-grid');
|
||||
@@ -1610,6 +1618,26 @@ export function updateReaderProgressBar() {
|
||||
} else if (readerScrollPctBadge) {
|
||||
readerScrollPctBadge.textContent = `${pct}%`;
|
||||
}
|
||||
|
||||
// Update Zen HUD elements if present
|
||||
const zenHudTitle = document.getElementById('zen-hud-title');
|
||||
const zenHudChapter = document.getElementById('zen-hud-chapter');
|
||||
const zenHudProgress = document.getElementById('zen-hud-progress');
|
||||
|
||||
if (zenHudTitle && currentBook) {
|
||||
zenHudTitle.textContent = currentBook.title || 'Книга';
|
||||
zenHudTitle.title = currentBook.title || 'Книга';
|
||||
}
|
||||
if (zenHudChapter && currentBook && currentBook.chapters) {
|
||||
const totalCh = currentBook.chapters.length;
|
||||
const ch = currentBook.chapters[currentChapterIndex];
|
||||
const chTitle = ch ? (ch.title || `Глава ${currentChapterIndex + 1}`) : `Глава ${currentChapterIndex + 1}`;
|
||||
zenHudChapter.textContent = totalCh > 1 ? `Гл. ${currentChapterIndex + 1}/${totalCh}` : chTitle;
|
||||
zenHudChapter.title = chTitle;
|
||||
}
|
||||
if (zenHudProgress) {
|
||||
zenHudProgress.textContent = `${pct}%`;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleReaderScroll() {
|
||||
@@ -1836,6 +1864,62 @@ export function positionSettingsPanel() {
|
||||
readerSettingsPanel.style.maxHeight = `${maxH}px`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Zen Focus Mode
|
||||
// -----------------------------------------------------------------------
|
||||
export function wakeZenHud() {
|
||||
if (!isZenMode) return;
|
||||
const hud = document.getElementById('reader-zen-hud');
|
||||
if (!hud) return;
|
||||
hud.classList.remove('hud-idle');
|
||||
if (zenHudIdleTimer) {
|
||||
clearTimeout(zenHudIdleTimer);
|
||||
}
|
||||
zenHudIdleTimer = setTimeout(() => {
|
||||
if (isZenMode && hud) {
|
||||
hud.classList.add('hud-idle');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function toggleZenMode(forceState = null) {
|
||||
const newState = (forceState !== null) ? Boolean(forceState) : !isZenMode;
|
||||
if (isZenMode === newState) return;
|
||||
isZenMode = newState;
|
||||
|
||||
const hud = document.getElementById('reader-zen-hud');
|
||||
const zenBtn = document.getElementById('reader-zen-btn');
|
||||
const readerSettingsPanel = document.getElementById('reader-settings-panel');
|
||||
|
||||
if (isZenMode) {
|
||||
document.body.classList.add('zen-mode-active');
|
||||
if (zenBtn) zenBtn.classList.add('active');
|
||||
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
|
||||
if (hud) {
|
||||
hud.classList.remove('hidden');
|
||||
hud.classList.remove('hud-idle');
|
||||
wakeZenHud();
|
||||
}
|
||||
if (!document.fullscreenElement && document.documentElement.requestFullscreen) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
document.body.classList.remove('zen-mode-active');
|
||||
if (zenBtn) zenBtn.classList.remove('active');
|
||||
if (hud) {
|
||||
hud.classList.add('hidden');
|
||||
hud.classList.remove('hud-idle');
|
||||
}
|
||||
if (zenHudIdleTimer) {
|
||||
clearTimeout(zenHudIdleTimer);
|
||||
zenHudIdleTimer = null;
|
||||
}
|
||||
if (document.fullscreenElement && document.exitFullscreen) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reader Module Initialization
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1865,6 +1949,62 @@ export function initReader() {
|
||||
const readerBackBtn = document.getElementById('reader-back-btn');
|
||||
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
|
||||
const readerTrainBtn = document.getElementById('reader-train-btn');
|
||||
const readerZenBtn = document.getElementById('reader-zen-btn');
|
||||
const zenHudExitBtn = document.getElementById('zen-hud-exit-btn');
|
||||
|
||||
if (readerZenBtn) {
|
||||
readerZenBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleZenMode();
|
||||
});
|
||||
}
|
||||
|
||||
if (zenHudExitBtn) {
|
||||
zenHudExitBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleZenMode(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Wake HUD on user activity during zen mode
|
||||
['mousemove', 'touchstart', 'scroll'].forEach(evt => {
|
||||
window.addEventListener(evt, () => {
|
||||
if (isZenMode) wakeZenHud();
|
||||
}, { passive: true });
|
||||
});
|
||||
|
||||
// Keyboard shortcut for Zen Mode (F / f / а / А) and exit on Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
if (tag === 'input' || tag === 'textarea' || (e.target && e.target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const readerView = document.getElementById('reader-view');
|
||||
const readerInterface = document.getElementById('reader-interface');
|
||||
const isReaderActive = readerView && !readerView.classList.contains('hidden') &&
|
||||
readerInterface && !readerInterface.classList.contains('hidden');
|
||||
|
||||
if (!isReaderActive) return;
|
||||
|
||||
if (e.key === 'Escape' && isZenMode) {
|
||||
e.preventDefault();
|
||||
toggleZenMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.key === 'f' || e.key === 'F' || e.key === 'а' || e.key === 'А') && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
toggleZenMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Native fullscreen sync
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (!document.fullscreenElement && isZenMode) {
|
||||
toggleZenMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
const popupCloseBtn = document.getElementById('popup-close-btn');
|
||||
const popupSaveBtn = document.getElementById('popup-save-btn');
|
||||
@@ -2450,6 +2590,7 @@ export function initReader() {
|
||||
|
||||
if (readerBackBtn) {
|
||||
readerBackBtn.addEventListener('click', () => {
|
||||
if (isZenMode) toggleZenMode(false);
|
||||
window.location.hash = '#reader';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,6 +49,111 @@ export function formatTranslationWithSpoiler(text) {
|
||||
return `${escapeHtml(cleanText)} <span class="grammar-hint-badge" title="Нажмите, чтобы показать форму" onclick="this.classList.toggle('active')"><span class="badge-label">ℹ️ форма</span><span class="badge-body">${escapeHtml(noteText)}</span></span>`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lightweight Native Markdown Parsers (Tables & Notes for Murphy Grammar)
|
||||
// Zero-dependency, safe escaping first, pure ES6
|
||||
// -----------------------------------------------------------------------
|
||||
export function renderMarkdownInline(text) {
|
||||
if (!text) return '';
|
||||
let str = escapeHtml(text);
|
||||
// Bold: **text**
|
||||
str = str.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// Inline code: `text`
|
||||
str = str.replace(/`([^`]+)`/g, '<code class="grammar-inline-code">$1</code>');
|
||||
// Italic: *text* (excluding already formatted strong)
|
||||
str = str.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||||
return str;
|
||||
}
|
||||
|
||||
export function renderMarkdownTable(mdText) {
|
||||
if (!mdText || typeof mdText !== 'string' || !mdText.trim()) return '';
|
||||
const rawLines = mdText.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0);
|
||||
const tableLines = rawLines.filter(l => l.startsWith('|') && l.endsWith('|'));
|
||||
if (tableLines.length < 2) {
|
||||
return `<div class="grammar-table-raw"><pre>${escapeHtml(mdText)}</pre></div>`;
|
||||
}
|
||||
|
||||
const splitRow = (rowStr) => {
|
||||
const inner = rowStr.replace(/^\|/, '').replace(/\|$/, '');
|
||||
return inner.split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'));
|
||||
};
|
||||
|
||||
const headers = splitRow(tableLines[0]);
|
||||
const alignRow = splitRow(tableLines[1]);
|
||||
const alignments = alignRow.map(col => {
|
||||
const tc = col.trim();
|
||||
if (tc.startsWith(':') && tc.endsWith(':')) return 'center';
|
||||
if (tc.endsWith(':')) return 'right';
|
||||
return 'left';
|
||||
});
|
||||
|
||||
const rows = [];
|
||||
for (let r = 2; r < tableLines.length; r++) {
|
||||
rows.push(splitRow(tableLines[r]));
|
||||
}
|
||||
|
||||
let html = '<div class="grammar-table-wrap"><table class="grammar-cheatsheet-table"><thead><tr>';
|
||||
headers.forEach((h, idx) => {
|
||||
const align = alignments[idx] || 'left';
|
||||
html += `<th style="text-align: ${align}">${renderMarkdownInline(h)}</th>`;
|
||||
});
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
rows.forEach(row => {
|
||||
html += '<tr>';
|
||||
row.forEach((cell, idx) => {
|
||||
const align = alignments[idx] || 'left';
|
||||
html += `<td style="text-align: ${align}">${renderMarkdownInline(cell)}</td>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
export function renderMarkdownNotes(mdText) {
|
||||
if (!mdText || typeof mdText !== 'string' || !mdText.trim()) return '';
|
||||
const lines = mdText.split(/\r?\n/);
|
||||
const out = [];
|
||||
let inList = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim();
|
||||
|
||||
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||
if (!inList) {
|
||||
out.push('<ul class="grammar-notes-list">');
|
||||
inList = true;
|
||||
}
|
||||
const itemText = trimmed.replace(/^[-*]\s+/, '');
|
||||
out.push(`<li>${renderMarkdownInline(itemText)}</li>`);
|
||||
continue;
|
||||
} else if (inList) {
|
||||
out.push('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('#### ')) {
|
||||
out.push(`<h5 class="grammar-note-h5">${renderMarkdownInline(trimmed.replace(/^####\s+/, ''))}</h5>`);
|
||||
} else if (trimmed.startsWith('### ')) {
|
||||
out.push(`<h4 class="grammar-note-h4">${renderMarkdownInline(trimmed.replace(/^###\s+/, ''))}</h4>`);
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
out.push(`<h3 class="grammar-note-h3">${renderMarkdownInline(trimmed.replace(/^##\s+/, ''))}</h3>`);
|
||||
} else if (trimmed.startsWith('> ')) {
|
||||
out.push(`<blockquote class="grammar-note-quote">${renderMarkdownInline(trimmed.replace(/^>\s+/, ''))}</blockquote>`);
|
||||
} else if (trimmed === '') {
|
||||
// blank line separator
|
||||
} else {
|
||||
out.push(`<p class="grammar-note-p">${renderMarkdownInline(trimmed)}</p>`);
|
||||
}
|
||||
}
|
||||
if (inList) {
|
||||
out.push('</ul>');
|
||||
}
|
||||
|
||||
return `<div class="grammar-notes-box">${out.join('\n')}</div>`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Toast Notifications
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -168,6 +273,11 @@ export function closeModal(modalElem) {
|
||||
// View Switcher Helper
|
||||
// -----------------------------------------------------------------------
|
||||
export function switchView(targetViewId) {
|
||||
if (targetViewId !== 'reader-view') {
|
||||
document.body.classList.remove('zen-mode-active');
|
||||
const hud = document.getElementById('reader-zen-hud');
|
||||
if (hud) hud.classList.add('hidden');
|
||||
}
|
||||
const allViews = [
|
||||
'dictionary-view',
|
||||
'srs-view',
|
||||
@@ -286,6 +396,9 @@ export const ui = {
|
||||
playTextSpeech,
|
||||
cleanPromptTranslation,
|
||||
formatTranslationWithSpoiler,
|
||||
renderMarkdownInline,
|
||||
renderMarkdownTable,
|
||||
renderMarkdownNotes,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
Reference in New Issue
Block a user