diff --git a/resources/js/singles/reader.ts b/resources/js/singles/reader.ts index 80a28cb..41bd19b 100644 --- a/resources/js/singles/reader.ts +++ b/resources/js/singles/reader.ts @@ -18,8 +18,7 @@ const DEFAULT_CONFIG: ReaderConfig = { pitch: 1.1, volume: 1, highlightColor: '#f1fcf9', - highlightStyle: - 'background-color: #f1fcf9; padding: 2px 4px; border-radius: 3px; color: #136F63;', + highlightStyle: 'background-color: #136F63; padding: 2px 4px; border-radius: 3px; color:white;', }; export default function handleArticleReader() { @@ -31,8 +30,6 @@ export default function handleArticleReader() { let utterance: SpeechSynthesisUtterance; let voices: SpeechSynthesisVoice[] = []; let thomasVoice: SpeechSynthesisVoice | null = null; - let originalContent = ''; - let words: string[] = []; let currentWordIndex = 0; /* ******************************************************** @@ -77,37 +74,132 @@ export default function handleArticleReader() { } } - // Fonctions de highlighting - function highlightWord(index: number): void { - if (!article || index >= words.length) return; + // ✅ HIGHLIGHTING AVANCÉ - Préserve la structure HTML + let textNodes: Text[] = []; + let wordBoundaries: { node: Text; startOffset: number; endOffset: number; word: string }[] = []; - // Restaurer le contenu original si c'est le premier mot - if (index === 0) { - article.innerHTML = originalContent; - } - - // Créer le nouveau HTML avec le mot highlighted - const highlightedWords = words.map((word, i) => { - if (i === index) { - return `${word}`; - } - return word; + 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; + }, }); - article.innerHTML = highlightedWords.join(' '); + 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], + }); + } + }); + + // 🐛 DEBUG: Afficher les mots détectés + console.log( + 'Mots détectés dans le DOM:', + wordBoundaries.map((b) => b.word) + ); + } + + function highlightWord(index: number): void { + if (!article || index >= wordBoundaries.length) { + console.log('❌ Highlight impossible:', { index, totalWords: wordBoundaries.length }); + return; + } + + // Supprimer le highlight précédent et reconstruire les boundaries + removeHighlight(); + buildWordBoundaries(); // ✅ Recalculer après cleanup + + // Vérifier à nouveau après reconstruction + if (index >= wordBoundaries.length) { + console.warn('❌ Index invalide après reconstruction:', { + index, + totalWords: wordBoundaries.length, + }); + return; + } + + const boundary = wordBoundaries[index]; + console.log('🎯 Highlighting mot #' + index + ':', boundary.word); + + // Vérifier que le nœud et les offsets sont encore valides + if (!boundary.node || !boundary.node.textContent) { + console.warn('❌ Nœud texte invalide'); + return; + } + + if ( + boundary.startOffset > boundary.node.textContent.length || + boundary.endOffset > boundary.node.textContent.length + ) { + console.warn('❌ Offsets invalides:', { + startOffset: boundary.startOffset, + endOffset: boundary.endOffset, + nodeLength: 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); + console.log('✅ Highlight réussi pour:', boundary.word); + } catch (error) { + console.warn('⚠️ Erreur lors du highlight:', error, boundary); + } } function removeHighlight(): void { - if (article && originalContent) { - article.innerHTML = originalContent; - } + 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 { - // Sauvegarder le contenu original et préparer les mots - originalContent = article ? article.innerHTML : ''; - words = text.split(/\s+/).filter((word) => word.trim().length > 0); + // ✅ Construire la carte des mots avec leur position dans le DOM + buildWordBoundaries(); currentWordIndex = 0; utterance = new SpeechSynthesisUtterance(text); @@ -123,9 +215,9 @@ export default function handleArticleReader() { utterance.voice = thomasVoice; } - // Event pour highlighter les mots pendant la lecture + // ✅ Event pour highlighter les mots pendant la lecture (basé sur les boundaries DOM) utterance.onboundary = (event) => { - if (event.name === 'word') { + if (event.name === 'word' && currentWordIndex < wordBoundaries.length) { highlightWord(currentWordIndex); currentWordIndex++; }