declare var Notyf: any; /* ******************************************************** * ******************* CONFIGURATION ******************* * ******************************************************** */ interface ReaderConfig { rate: number; pitch: number; volume: number; highlightColor: string; highlightStyle: string; } const DEFAULT_CONFIG: ReaderConfig = { rate: 1.2, pitch: 1.1, volume: 1, highlightColor: '#f1fcf9', highlightStyle: 'background-color: #136F63; padding: 2px 4px; border-radius: 3px; color:white;', }; export default function handleArticleReader() { // ✅ DÉCLARATION DES VARIABLES const playPauseButton = document.getElementById('listen-article'); const stopReadingButton = document.getElementById('stop-reading'); const article = document.querySelector('.article-content'); let utterance: SpeechSynthesisUtterance; let voices: SpeechSynthesisVoice[] = []; let thomasVoice: SpeechSynthesisVoice | null = null; let currentWordIndex = 0; /* ******************************************************** * ************** MÉTHODES INTERNES ********************* * ******************************************************** */ // Fonctions de gestion du bouton function setReaderButtonReading(): void { if (!playPauseButton) return; playPauseButton.setAttribute('data-reading-status', 'playing'); playPauseButton.setAttribute('title', 'Mettre en pause la lecture vocale'); playPauseButton.setAttribute('aria-label', 'Mettre en pause la lecture vocale'); } function setReaderButtonStopped(): void { if (!playPauseButton) return; playPauseButton.setAttribute('data-reading-status', 'stopped'); playPauseButton.setAttribute('title', "Lancer la lecture vocale de l'article"); playPauseButton.setAttribute('aria-label', "Lecture vocale de l'article"); } function setReaderButtonPaused(): void { if (!playPauseButton) return; playPauseButton.setAttribute('data-reading-status', 'paused'); playPauseButton.setAttribute('title', 'Reprendre la lecture vocale'); playPauseButton.setAttribute('aria-label', 'Reprendre la lecture vocale'); } // Charger les voix et trouver Thomas function loadVoices(): void { voices = speechSynthesis.getVoices(); // Chercher la voix Thomas (peut être "Thomas" ou contenir "Thomas") thomasVoice = voices.find( (voice) => voice.name.toLowerCase().includes('thomas') || voice.name === 'Thomas' ) || null; // Si Thomas n'est pas trouvé, prendre une voix française par défaut if (!thomasVoice) { thomasVoice = voices.find((voice) => voice.lang.startsWith('fr')) || null; } } // ✅ HIGHLIGHTING AVANCÉ - Préserve la structure HTML let textNodes: Text[] = []; let wordBoundaries: { node: Text; startOffset: number; endOffset: number; word: string }[] = []; function collectTextNodes(element: Element): Text[] { const nodes: Text[] = []; const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { // Ignorer les nœuds texte vides ou uniquement des espaces return node.textContent?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; }, }); let node; while ((node = walker.nextNode())) { nodes.push(node as Text); } return nodes; } function buildWordBoundaries(): void { wordBoundaries = []; textNodes = collectTextNodes(article as Element); textNodes.forEach((textNode) => { const text = textNode.textContent || ''; const wordRegex = /\S+/g; // Mots (non-espaces) let match; while ((match = wordRegex.exec(text)) !== null) { wordBoundaries.push({ node: textNode, startOffset: match.index, endOffset: match.index + match[0].length, word: match[0], }); } }); } function highlightWord(index: number): void { if (!article || index >= wordBoundaries.length) return; // Supprimer le highlight précédent et reconstruire les boundaries removeHighlight(); buildWordBoundaries(); // Vérifier à nouveau après reconstruction if (index >= wordBoundaries.length) return; const boundary = wordBoundaries[index]; // Vérifier que le nœud et les offsets sont encore valides if (!boundary.node || !boundary.node.textContent) return; if ( boundary.startOffset > boundary.node.textContent.length || boundary.endOffset > boundary.node.textContent.length ) return; try { const range = document.createRange(); range.setStart(boundary.node, boundary.startOffset); range.setEnd(boundary.node, boundary.endOffset); // Créer le span de highlight const highlightSpan = document.createElement('span'); highlightSpan.className = 'speech-highlight'; highlightSpan.style.cssText = DEFAULT_CONFIG.highlightStyle; range.surroundContents(highlightSpan); } catch (error) { // Ignorer silencieusement les erreurs de highlighting } } function removeHighlight(): void { if (!article) return; const highlights = article.querySelectorAll('.speech-highlight'); highlights.forEach((highlight) => { const parent = highlight.parentNode; if (parent) { // Remplacer le span par son contenu while (highlight.firstChild) { parent.insertBefore(highlight.firstChild, highlight); } parent.removeChild(highlight); // Normaliser pour fusionner les nœuds texte adjacents parent.normalize(); } }); } // Création de l'utterance function createUtterance(text: string): SpeechSynthesisUtterance { // ✅ Construire la carte des mots avec leur position dans le DOM buildWordBoundaries(); currentWordIndex = 0; utterance = new SpeechSynthesisUtterance(text); utterance.lang = 'fr-FR'; // Appliquer la configuration utterance.rate = DEFAULT_CONFIG.rate; utterance.pitch = DEFAULT_CONFIG.pitch; utterance.volume = DEFAULT_CONFIG.volume; // Utiliser Thomas par défaut if (thomasVoice) { utterance.voice = thomasVoice; } // ✅ Event pour highlighter les mots pendant la lecture (basé sur les boundaries DOM) utterance.onboundary = (event) => { if (event.name === 'word' && currentWordIndex < wordBoundaries.length) { highlightWord(currentWordIndex); currentWordIndex++; } }; utterance.onend = () => { setReaderButtonStopped(); removeHighlight(); currentWordIndex = 0; }; utterance.onerror = (event) => { // Ne pas logger les interruptions volontaires (cancel/stop) if (event.error !== 'interrupted') { console.error('Erreur lors de la lecture:', event); } setReaderButtonStopped(); removeHighlight(); currentWordIndex = 0; }; return utterance; } // Fonctions de contrôle de lecture function playReading(): void { if (article) { const text = (article as HTMLElement).innerText.trim(); if (text) { createUtterance(text); speechSynthesis.speak(utterance); setReaderButtonReading(); showReaderStartedMessage(); } } } function pauseReading(): void { speechSynthesis.pause(); setReaderButtonPaused(); } function stopReading(): void { speechSynthesis.resume(); speechSynthesis.cancel(); setReaderButtonStopped(); removeHighlight(); currentWordIndex = 0; speechSynthesis.paused = false; } /* ******************************************************** * *************** INITIALISATION ********************** * ******************************************************** */ // Vérifier si la synthèse vocale est déjà en cours au chargement de la page if (speechSynthesis.speaking) { speechSynthesis.cancel(); setReaderButtonStopped(); } // Arrêter la lecture vocale quand l'utilisateur quitte la page window.addEventListener('beforeunload', () => { if (speechSynthesis.speaking) { speechSynthesis.cancel(); setReaderButtonStopped(); } }); // Arrêter la lecture vocale quand l'onglet devient invisible document.addEventListener('visibilitychange', () => { if (document.hidden && speechSynthesis.speaking) { speechSynthesis.pause(); } else if (!document.hidden && speechSynthesis.paused) { speechSynthesis.resume(); setReaderButtonReading(); } }); // Charger les voix au démarrage et quand elles changent loadVoices(); speechSynthesis.onvoiceschanged = loadVoices; // Event listener principal playPauseButton?.addEventListener('click', () => { console.log(speechSynthesis); if (speechSynthesis.speaking && !speechSynthesis.paused) { // En cours de lecture → Pause pauseReading(); } else if (speechSynthesis.paused) { // En pause → Reprendre speechSynthesis.resume(); setReaderButtonReading(); } else { // Arrêté → Démarrer playReading(); } }); stopReadingButton?.addEventListener('click', () => { stopReading(); }); } function showReaderStartedMessage() { const notyf = new Notyf({ duration: 2000, ripple: false, dismissible: true, types: [ { type: 'success', background: '#10B981', icon: { className: 'notyf__icon--success', tagName: 'i', text: '🔊', }, }, ], position: { x: 'right', y: 'top', }, }); notyf.success(`Lecture de l'article lancée !`); }