405 lines
14 KiB
JavaScript
405 lines
14 KiB
JavaScript
// =======================================================================
|
||
// 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, '"')
|
||
.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)} <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
|
||
// -----------------------------------------------------------------------
|
||
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;
|