|
// ===== DOM ELEMENTS & STATE ===== |
|
document.addEventListener('DOMContentLoaded', () => { |
|
// --- State --- |
|
let state = { |
|
referenceText: '', |
|
referenceWords: [], |
|
settings: { |
|
isCaseSensitive: false, |
|
isPunctuationSensitive: false, |
|
theme: 'system' // 'light', 'dark', 'system' |
|
}, |
|
currentWordIndex: 0 |
|
}; |
|
|
|
// --- DOM Selectors --- |
|
const practiceInput = document.getElementById('practice-input'); |
|
const practiceDisplay = document.getElementById('practice-display'); |
|
const wordCountEl = document.getElementById('word-count'); |
|
|
|
const refNoteBtn = document.getElementById('ref-note-btn'); |
|
const deleteBtn = document.getElementById('delete-btn'); |
|
const settingsBtn = document.getElementById('settings-btn'); |
|
const hintBtn = document.getElementById('hint-btn'); |
|
const nextBtn = document.getElementById('next-btn'); |
|
|
|
// Reference Modal |
|
const refModal = document.getElementById('reference-modal'); |
|
const refTextarea = document.getElementById('ref-textarea'); |
|
const refFileInput = document.getElementById('ref-file-input'); |
|
const fileNameEl = document.getElementById('file-name'); |
|
const manualInputArea = document.getElementById('manual-input-area'); |
|
const fileInputArea = document.getElementById('file-input-area'); |
|
const addRefBtn = document.getElementById('add-ref-btn'); |
|
const closeRefModalBtn = document.getElementById('close-ref-modal-btn'); |
|
|
|
// Settings Modal |
|
const settingsModal = document.getElementById('settings-modal'); |
|
const caseSensitiveToggle = document.getElementById('case-sensitive-toggle'); |
|
const punctuationSensitiveToggle = document.getElementById('punctuation-sensitive-toggle'); |
|
const closeSettingsModalBtn = document.getElementById('close-settings-modal-btn'); |
|
|
|
// Hint/Next Word Modal |
|
const hintModal = document.getElementById('hint-modal'); |
|
const hintModalTitle = document.getElementById('hint-modal-title'); |
|
const hintModalContent = document.getElementById('hint-modal-content'); |
|
const closeHintModalBtn = document.getElementById('close-hint-modal-btn'); |
|
const insertWordBtn = document.getElementById('insert-word-btn'); |
|
|
|
// Delete Confirmation Modal |
|
const deleteModal = document.getElementById('delete-modal'); |
|
const confirmDeleteBtn = document.getElementById('confirm-delete-btn'); |
|
const cancelDeleteBtn = document.getElementById('cancel-delete-btn'); |
|
|
|
// Toast Notification |
|
const toast = document.getElementById('toast'); |
|
const toastMessage = document.getElementById('toast-message'); |
|
|
|
// Array of all modals |
|
const allModals = [refModal, settingsModal, hintModal, deleteModal]; |
|
|
|
// ===== CORE LOGIC ===== |
|
|
|
/** |
|
* Updates the practice display with colored characters based on user input. |
|
*/ |
|
const updateHighlighting = () => { |
|
const typedText = practiceInput.value; |
|
const punctuationRegex = /[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/g; |
|
|
|
// Compute normalized reference and typed text (remove whitespace always, punctuation if not sensitive) |
|
let normRef = state.referenceText.replace(/\s/g, ''); |
|
if (!state.settings.isPunctuationSensitive) { |
|
normRef = normRef.replace(punctuationRegex, ''); |
|
} |
|
|
|
let normTyped = typedText.replace(/\s/g, ''); |
|
if (!state.settings.isPunctuationSensitive) { |
|
normTyped = normTyped.replace(punctuationRegex, ''); |
|
} |
|
|
|
// Build highlighted HTML |
|
let html = ''; |
|
let normIdx = 0; |
|
for (let j = 0; j < typedText.length; j++) { |
|
const char = typedText[j]; |
|
if (/\s/.test(char)) { |
|
html += char; // Preserve whitespace without coloring |
|
} else { |
|
let colorClass = 'text-correct dark:text-correct-dark'; |
|
let isMainChar = state.settings.isPunctuationSensitive || !punctuationRegex.test(char); |
|
if (isMainChar) { |
|
if (normIdx >= normRef.length) { |
|
colorClass = 'text-wrong dark:text-wrong-dark'; |
|
} else { |
|
const refChar = normRef[normIdx]; |
|
const isCorrect = state.settings.isCaseSensitive |
|
? char === refChar |
|
: char.toLowerCase() === refChar.toLowerCase(); |
|
colorClass = isCorrect ? 'text-correct dark:text-correct-dark' : 'text-wrong dark:text-wrong-dark'; |
|
normIdx++; |
|
} |
|
} |
|
// Ignored punctuation is always correct |
|
html += `<span class="${colorClass}">${char}</span>`; |
|
} |
|
} |
|
|
|
practiceDisplay.innerHTML = html; |
|
|
|
// Compute word starts for normalized positions |
|
const wordStarts = [0]; |
|
let pos = 0; |
|
for (let word of state.referenceWords) { |
|
let normWord = state.settings.isPunctuationSensitive ? word : word.replace(punctuationRegex, ''); |
|
pos += normWord.length; |
|
wordStarts.push(pos); |
|
} |
|
|
|
const len = normTyped.length; |
|
|
|
// Find current word index |
|
let currentWordIndex = 0; |
|
for (let i = 0; i < state.referenceWords.length; i++) { |
|
if (wordStarts[i] <= len && len < wordStarts[i + 1]) { |
|
currentWordIndex = i; |
|
break; |
|
} |
|
} |
|
if (len >= wordStarts[state.referenceWords.length]) { |
|
currentWordIndex = state.referenceWords.length - 1; |
|
} |
|
state.currentWordIndex = currentWordIndex; |
|
|
|
// Compute completed words |
|
let completed = 0; |
|
for (let i = 1; i < wordStarts.length; i++) { |
|
if (len >= wordStarts[i]) { |
|
completed = i; |
|
} else { |
|
break; |
|
} |
|
} |
|
updateWordCount(completed, state.referenceWords.length); |
|
syncScroll(); |
|
}; |
|
|
|
/** |
|
* Updates the UI state to enable or disable practice controls. |
|
* @param {boolean} isEnabled - Whether to enable or disable. |
|
*/ |
|
const setPracticeState = (isEnabled) => { |
|
practiceInput.disabled = !isEnabled; |
|
deleteBtn.disabled = !isEnabled; |
|
hintBtn.disabled = !isEnabled; |
|
nextBtn.disabled = !isEnabled; |
|
if (!isEnabled) { |
|
practiceInput.value = ''; |
|
practiceDisplay.innerHTML = ''; |
|
practiceInput.placeholder = 'Add a reference note to start practicing...'; |
|
updateWordCount(0, 0); |
|
} else { |
|
practiceInput.placeholder = 'Start typing here...'; |
|
} |
|
}; |
|
|
|
/** |
|
* Updates the word count display. |
|
*/ |
|
const updateWordCount = (typed, total) => { |
|
wordCountEl.textContent = `Words: ${typed} / ${total}`; |
|
}; |
|
|
|
/** |
|
* Synchronizes the scroll position of the input and display areas. |
|
*/ |
|
const syncScroll = () => { |
|
practiceDisplay.scrollTop = practiceInput.scrollTop; |
|
}; |
|
|
|
// ===== API & EVENT HANDLERS ===== |
|
|
|
/** |
|
* Shows a toast notification. |
|
* @param {string} message - The message to display. |
|
* @param {('success'|'error')} type - The type of message. |
|
*/ |
|
const showToast = (message, type = 'success') => { |
|
toastMessage.textContent = message; |
|
toast.className = `fixed bottom-5 right-5 text-white py-2 px-5 rounded-lg shadow-lg transition-opacity duration-300 ${type === 'success' ? 'bg-green-600' : 'bg-red-600'}`; |
|
toast.classList.remove('opacity-0'); |
|
setTimeout(() => toast.classList.add('opacity-0'), 3000); |
|
}; |
|
|
|
/** |
|
* Fetches the reference text and words from the backend and updates the state. |
|
*/ |
|
const fetchReference = async () => { |
|
try { |
|
const response = await fetch('/api/get-all-words'); |
|
const data = await response.json(); |
|
if (data.status === 'success') { |
|
state.referenceText = data.text; |
|
state.referenceWords = data.words; |
|
setPracticeState(state.referenceWords.length > 0); |
|
updateHighlighting(); |
|
} |
|
} catch (error) { |
|
console.error('Error fetching reference:', error); |
|
showToast('Could not fetch reference text.', 'error'); |
|
} |
|
}; |
|
|
|
// --- Modal Controls --- |
|
const openModal = (modal) => modal.classList.remove('hidden'); |
|
const closeModal = (modal) => modal.classList.add('hidden'); |
|
const closeAllModals = () => allModals.forEach(closeModal); |
|
|
|
refNoteBtn.addEventListener('click', () => openModal(refModal)); |
|
closeRefModalBtn.addEventListener('click', () => closeAllModals()); |
|
settingsBtn.addEventListener('click', () => openModal(settingsModal)); |
|
closeSettingsModalBtn.addEventListener('click', () => closeAllModals()); |
|
closeHintModalBtn.addEventListener('click', () => closeAllModals()); |
|
cancelDeleteBtn.addEventListener('click', () => closeAllModals()); |
|
|
|
// Close modals when clicking outside or pressing Escape |
|
allModals.forEach(modal => { |
|
modal.addEventListener('click', (e) => { |
|
if (e.target === modal) { |
|
closeModal(modal); |
|
} |
|
}); |
|
}); |
|
|
|
document.addEventListener('keydown', (e) => { |
|
if (e.key === 'Escape') { |
|
closeAllModals(); |
|
} |
|
}); |
|
|
|
// Reference modal input method toggle |
|
document.querySelectorAll('input[name="inputMethod"]').forEach(radio => { |
|
radio.addEventListener('change', (e) => { |
|
if (e.target.value === 'manual') { |
|
manualInputArea.classList.remove('hidden'); |
|
fileInputArea.classList.add('hidden'); |
|
} else { |
|
manualInputArea.classList.add('hidden'); |
|
fileInputArea.classList.remove('hidden'); |
|
} |
|
}); |
|
}); |
|
|
|
refFileInput.addEventListener('change', () => { |
|
fileNameEl.textContent = refFileInput.files[0]?.name || 'No file chosen'; |
|
}); |
|
|
|
addRefBtn.addEventListener('click', async () => { |
|
const inputMethod = document.querySelector('input[name="inputMethod"]:checked').value; |
|
let response; |
|
|
|
try { |
|
if (inputMethod === 'manual') { |
|
const text = refTextarea.value.trim(); |
|
if (!text) { |
|
showToast('Please enter some text.', 'error'); |
|
return; |
|
} |
|
response = await fetch('/api/set-reference', { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ text }), |
|
}); |
|
} else { |
|
const file = refFileInput.files[0]; |
|
if (!file) { |
|
showToast('Please select a file.', 'error'); |
|
return; |
|
} |
|
const formData = new FormData(); |
|
formData.append('referenceFile', file); |
|
response = await fetch('/api/upload-reference', { |
|
method: 'POST', |
|
body: formData, |
|
}); |
|
} |
|
|
|
const data = await response.json(); |
|
if (data.status === 'success') { |
|
showToast(data.message); |
|
await fetchReference(); |
|
closeAllModals(); |
|
refTextarea.value = ''; |
|
refFileInput.value = null; |
|
fileNameEl.textContent = ''; |
|
} else { |
|
showToast(data.message, 'error'); |
|
} |
|
} catch (error) { |
|
console.error('Error setting reference:', error); |
|
showToast('An unexpected error occurred.', 'error'); |
|
} |
|
}); |
|
|
|
// Handle delete button click, opens confirmation modal |
|
deleteBtn.addEventListener('click', () => openModal(deleteModal)); |
|
|
|
// Handle the confirm deletion action |
|
confirmDeleteBtn.addEventListener('click', async () => { |
|
try { |
|
const response = await fetch('/api/delete-reference', { method: 'POST' }); |
|
const data = await response.json(); |
|
if (data.status === 'success') { |
|
showToast(data.message); |
|
state.referenceText = ''; |
|
state.referenceWords = []; |
|
setPracticeState(false); |
|
} else { |
|
showToast(data.message, 'error'); |
|
} |
|
} catch (error) { |
|
showToast('Error deleting reference.', 'error'); |
|
} |
|
closeAllModals(); |
|
}); |
|
|
|
hintBtn.addEventListener('click', async () => { |
|
try { |
|
const response = await fetch(`/api/get-word?index=${state.currentWordIndex}`); |
|
const data = await response.json(); |
|
if (data.status === 'success') { |
|
hintModalTitle.textContent = "Hint"; |
|
hintModalContent.textContent = data.word; |
|
openModal(hintModal); |
|
} else { |
|
showToast(data.message, 'error'); |
|
} |
|
} catch (error) { |
|
showToast('Could not fetch hint.', 'error'); |
|
} |
|
}); |
|
|
|
nextBtn.addEventListener('click', async () => { |
|
try { |
|
const nextIndex = state.currentWordIndex + 1; |
|
const response = await fetch(`/api/get-word?index=${nextIndex}`); |
|
const data = await response.json(); |
|
if (data.status === 'success') { |
|
hintModalTitle.textContent = "Next Word"; |
|
hintModalContent.textContent = data.word; |
|
openModal(hintModal); |
|
} else { |
|
showToast('You are at the end of the text.', 'error'); |
|
} |
|
} catch (error) { |
|
showToast('Could not fetch next word.', 'error'); |
|
} |
|
}); |
|
|
|
// Handle the "Enter" button click in the hint modal |
|
insertWordBtn.addEventListener('click', () => { |
|
const word = hintModalContent.textContent; |
|
const currentValue = practiceInput.value.trim(); |
|
|
|
// Add a space if the input is not empty |
|
const newText = currentValue.length > 0 ? currentValue + ' ' + word : word; |
|
|
|
practiceInput.value = newText; |
|
practiceInput.focus(); // Keep focus on the input box |
|
updateHighlighting(); // Recalculate highlighting |
|
closeAllModals(); |
|
}); |
|
|
|
// --- Settings Logic --- |
|
const saveSettings = () => localStorage.setItem('writingPracticeSettings', JSON.stringify(state.settings)); |
|
|
|
const loadSettings = () => { |
|
const saved = localStorage.getItem('writingPracticeSettings'); |
|
if (saved) { |
|
state.settings = JSON.parse(saved); |
|
} |
|
caseSensitiveToggle.checked = state.settings.isCaseSensitive; |
|
punctuationSensitiveToggle.checked = state.settings.isPunctuationSensitive; |
|
document.querySelector(`input[name="theme"][value="${state.settings.theme}"]`).checked = true; |
|
applyTheme(); |
|
}; |
|
|
|
caseSensitiveToggle.addEventListener('change', () => { |
|
state.settings.isCaseSensitive = caseSensitiveToggle.checked; |
|
saveSettings(); |
|
updateHighlighting(); |
|
}); |
|
|
|
punctuationSensitiveToggle.addEventListener('change', () => { |
|
state.settings.isPunctuationSensitive = punctuationSensitiveToggle.checked; |
|
saveSettings(); |
|
updateHighlighting(); |
|
}); |
|
|
|
// --- Theme Logic --- |
|
const applyTheme = () => { |
|
const root = document.documentElement; |
|
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; |
|
|
|
if (state.settings.theme === 'dark' || (state.settings.theme === 'system' && systemPrefersDark)) { |
|
root.classList.add('dark'); |
|
} else { |
|
root.classList.remove('dark'); |
|
} |
|
}; |
|
|
|
document.querySelectorAll('input[name="theme"]').forEach(radio => { |
|
radio.addEventListener('change', (e) => { |
|
state.settings.theme = e.target.value; |
|
saveSettings(); |
|
applyTheme(); |
|
}); |
|
}); |
|
|
|
// --- Event Listeners --- |
|
practiceInput.addEventListener('input', updateHighlighting); |
|
practiceInput.addEventListener('scroll', syncScroll); |
|
|
|
// Add keyboard shortcuts |
|
document.addEventListener('keydown', (e) => { |
|
if (e.ctrlKey && e.altKey) { |
|
const key = e.key.toLowerCase(); |
|
if (key === 'h') { |
|
if (!hintBtn.disabled) { |
|
hintBtn.click(); |
|
e.preventDefault(); |
|
} |
|
} else if (key === 'n') { |
|
if (!nextBtn.disabled) { |
|
nextBtn.click(); |
|
e.preventDefault(); |
|
} |
|
} |
|
} |
|
}); |
|
|
|
// --- Initialization --- |
|
const initializeApp = () => { |
|
loadSettings(); |
|
fetchReference(); // See if there's a reference on the server from a previous session |
|
}; |
|
|
|
initializeApp(); |
|
}); |