// ======================================================================= // Notes Application - UI & Utilities Module (Native ES Module) // ======================================================================= export function escapeHtml(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export function escapeRegex(string) { if (!string) return ''; return String(string).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export function slugify(text) { if (!text) return 'book'; return text.toString().toLowerCase().trim() .replace(/[^\w\s-]/g, '') .replace(/[\s_-]+/g, '-') .replace(/^-+|-+$/g, ''); } export function shuffleArray(array) { if (!array || !Array.isArray(array)) return []; for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; } export function cleanPromptTranslation(text) { if (!text || typeof text !== 'string') return ''; // Strip grammatical inflection notes that contain Latin words to prevent spoilers in tests return text.replace(/\s*\([а-яА-ЯёЁ0-9\s.,-]*\bот\s+[a-zA-Z\x27-]+\)/gi, '').trim(); } export function formatTranslationWithSpoiler(text) { if (!text || typeof text !== 'string') return ''; const noteMatch = text.match(/\s*\(([а-яА-ЯёЁ0-9\s.,-]*\bот\s+[a-zA-Z\x27-]+)\)/i); if (!noteMatch) return escapeHtml(text); const cleanText = text.replace(noteMatch[0], '').trim(); const noteText = noteMatch[1]; return `${escapeHtml(cleanText)} ℹ️ форма${escapeHtml(noteText)}`; } // ----------------------------------------------------------------------- // 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, '$1'); // Inline code: `text` str = str.replace(/`([^`]+)`/g, '$1'); // Italic: *text* (excluding already formatted strong) str = str.replace(/(?$1'); 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 `
${escapeHtml(mdText)}
`; } const splitRow = (rowStr) => { const inner = rowStr.replace(/^\|/, '').replace(/\|$/, ''); return inner.split(/(? 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 = '
'; headers.forEach((h, idx) => { const align = alignments[idx] || 'left'; html += ``; }); html += ''; rows.forEach(row => { html += ''; row.forEach((cell, idx) => { const align = alignments[idx] || 'left'; html += ``; }); html += ''; }); html += '
${renderMarkdownInline(h)}
${renderMarkdownInline(cell)}
'; 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(''); inList = false; } if (trimmed.startsWith('#### ')) { out.push(`
${renderMarkdownInline(trimmed.replace(/^####\s+/, ''))}
`); } else if (trimmed.startsWith('### ')) { out.push(`

${renderMarkdownInline(trimmed.replace(/^###\s+/, ''))}

`); } else if (trimmed.startsWith('## ')) { out.push(`

${renderMarkdownInline(trimmed.replace(/^##\s+/, ''))}

`); } else if (trimmed.startsWith('> ')) { out.push(`
${renderMarkdownInline(trimmed.replace(/^>\s+/, ''))}
`); } else if (trimmed === '') { // blank line separator } else { out.push(`

${renderMarkdownInline(trimmed)}

`); } } if (inList) { out.push(''); } return `
${out.join('\n')}
`; } // ----------------------------------------------------------------------- // Toast Notifications // ----------------------------------------------------------------------- export function showToast(message, type = 'info') { if (!message) return; let toastContainer = document.getElementById('notes-toast-container'); if (!toastContainer) { toastContainer = document.createElement('div'); toastContainer.id = 'notes-toast-container'; toastContainer.style.cssText = ` position: fixed; bottom: 24px; right: 24px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; `; if (document.body) { document.body.appendChild(toastContainer); } else { return; } } const toast = document.createElement('div'); const colors = { success: '#10b981', error: '#ef4444', warning: '#f59e0b', info: '#3b82f6' }; const bg = colors[type] || colors.info; toast.style.cssText = ` background: ${bg}; color: #ffffff; padding: 10px 18px; border-radius: 8px; font-size: 14px; font-weight: 500; box-shadow: 0 4px 14px rgba(0,0,0,0.25); opacity: 0; transform: translateY(12px); transition: all 0.25s ease; pointer-events: auto; max-width: 340px; `; toast.textContent = message; toastContainer.appendChild(toast); requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateY(0)'; }); setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateY(12px)'; setTimeout(() => toast.remove(), 250); }, 3500); } // ----------------------------------------------------------------------- // Theme Management // ----------------------------------------------------------------------- export function initTheme() { const themeToggle = document.getElementById('theme-toggle'); const mobileThemeBtn = document.getElementById('mobile-theme-btn'); let savedTheme = 'dark'; try { savedTheme = localStorage.getItem('notes_theme') || 'dark'; } catch (e) {} document.documentElement.setAttribute('data-theme', savedTheme); function toggle() { const current = document.documentElement.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); try { localStorage.setItem('notes_theme', next); } catch (e) {} } if (themeToggle) { themeToggle.addEventListener('click', toggle); } if (mobileThemeBtn) { mobileThemeBtn.addEventListener('click', () => { toggle(); showToast(`Тема переключена`, 'info'); }); } } // ----------------------------------------------------------------------- // Modal Open / Close Helpers (Accepts HTMLElement or string selector/ID) // ----------------------------------------------------------------------- export function openModal(modalElem) { if (!modalElem) return; const el = typeof modalElem === 'string' ? (document.getElementById(modalElem) || document.querySelector(modalElem)) : modalElem; if (el) el.classList.remove('hidden'); } export function closeModal(modalElem) { if (!modalElem) return; const el = typeof modalElem === 'string' ? (document.getElementById(modalElem) || document.querySelector(modalElem)) : modalElem; if (el) el.classList.add('hidden'); } // ----------------------------------------------------------------------- // 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', 'reader-view', 'cefr-view', 'grammar-view', 'lesson-view', 'lesson-runner-view' ]; allViews.forEach(id => { const el = document.getElementById(id); if (el) { if (id === targetViewId) { el.classList.remove('hidden'); } else { el.classList.add('hidden'); } } }); } // ----------------------------------------------------------------------- // Header Stat Counters // ----------------------------------------------------------------------- export function updateHeaderStats(total = 0, today = 0, srsCount = 0) { const statTotal = document.getElementById('stat-total'); const statToday = document.getElementById('stat-today'); const srsBadge = document.getElementById('srs-badge'); const tabSrsBadge = document.getElementById('tab-srs-badge'); const drawerSrsBadge = document.getElementById('drawer-srs-badge'); const drawerStatTotal = document.getElementById('drawer-stat-total'); const drawerStatToday = document.getElementById('drawer-stat-today'); const drawerStatDue = document.getElementById('drawer-stat-due'); const formattedToday = today > 0 ? `+${today}` : today; if (statTotal) statTotal.textContent = total; if (statToday) statToday.textContent = formattedToday; if (drawerStatTotal) drawerStatTotal.textContent = total; if (drawerStatToday) drawerStatToday.textContent = formattedToday; if (drawerStatDue) drawerStatDue.textContent = srsCount; if (srsBadge) { srsBadge.textContent = srsCount; srsBadge.style.display = srsCount > 0 ? 'inline-block' : 'none'; } if (tabSrsBadge) { tabSrsBadge.textContent = srsCount; tabSrsBadge.classList.toggle('hidden', srsCount === 0); } if (drawerSrsBadge) { drawerSrsBadge.textContent = srsCount; drawerSrsBadge.classList.toggle('hidden', srsCount === 0); } } // ----------------------------------------------------------------------- // Audio Player Helper (Safe stub / audio stream / Piper TTS) // ----------------------------------------------------------------------- let activeAudio = null; export function playAudio(url, speed = 1.0) { if (!url) return null; try { if (activeAudio) { try { activeAudio.pause(); activeAudio.currentTime = 0; } catch (e) {} } const audio = new Audio(url); if (speed && speed !== 1.0) { audio.playbackRate = speed; } activeAudio = audio; audio.play().catch(e => console.warn('Audio playback not supported or blocked:', e)); return audio; } catch (e) { console.warn('Audio play error:', e); return null; } } export function playTextSpeech(text, lang = 'en', speed = 1.0, voice = null) { if (!text || !text.trim()) return null; const isRu = lang && lang.toLowerCase().startsWith('ru'); let savedVoice = null; try { savedVoice = isRu ? localStorage.getItem('notes_tts_voice_ru') : localStorage.getItem('notes_tts_voice_en'); } catch (e) {} const activeVoice = voice || savedVoice; let url = `/api/tts?text=${encodeURIComponent(text.trim())}&lang=${lang || 'en'}&speed=${speed}`; if (activeVoice) { url += `&voice=${encodeURIComponent(activeVoice)}`; } return playAudio(url, speed); } // ----------------------------------------------------------------------- // Unified UI Object Export (for modules importing `{ ui }` or `ui`) // ----------------------------------------------------------------------- export const ui = { escapeHtml, escapeRegex, slugify, shuffleArray, showToast, initTheme, openModal, closeModal, switchView, updateHeaderStats, playAudio, playTextSpeech, cleanPromptTranslation, formatTranslationWithSpoiler, renderMarkdownInline, renderMarkdownTable, renderMarkdownNotes, }; export default ui;