carhop__dynamiques-theme__P.../resources/js/singles/reader.ts
Nonimart 6328fc13b5
All checks were successful
continuous-integration/drone/push Build is passing
FEATURE Optimizing article reader behaviour
2025-09-30 12:09:50 +02:00

324 lines
9.8 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', '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 !`);
}