344 lines
10 KiB
TypeScript
344 lines
10 KiB
TypeScript
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', 'Arrêter la lecture vocale');
|
|
playPauseButton.setAttribute('aria-label', 'Arrêter 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],
|
|
});
|
|
}
|
|
});
|
|
|
|
// 🐛 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) 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) => {
|
|
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.cancel();
|
|
setReaderButtonStopped();
|
|
removeHighlight();
|
|
currentWordIndex = 0;
|
|
}
|
|
|
|
/* ********************************************************
|
|
* *************** 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', () => {
|
|
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();
|
|
setReaderButtonStopped();
|
|
});
|
|
}
|
|
|
|
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 !`);
|
|
}
|