1/** 2 * ASB - Accessibility Settings Bar 3 * version 0.5 4 */ 5 6 7 8 9window.addEventListener('load', () => { 10 function ObterTamanhoFonte(seletorComClasseAcessibilidade) { 11 let tamanho = window.getComputedStyle(seletorComClasseAcessibilidade, null) 12 .getPropertyValue('font-size'); 13 return parseFloat(tamanho); 14 } 15 16 function ControlaTamanhoElementos(seletoresComClasseAcessibilidade, aumentar, normal) 17 { 18 for (let i = 0; i < seletoresComClasseAcessibilidade.length; i++) { 19 const element = seletoresComClasseAcessibilidade[i]; 20 let fontSizeAtual = 0; 21 22 if (normal) 23 { 24 element.style.fontSize = '1em'; 25 } 26 else 27 { 28 if (aumentar) 29 fontSizeAtual = ObterTamanhoFonte(element) + 1; 30 else 31 fontSizeAtual = ObterTamanhoFonte(element) - 1; 32 33 element.style.fontSize = fontSizeAtual.toString() + 'px'; 34 } 35 } 36 } 37 38 const incBtn = document.querySelector('#increase-plugin-ac'); 39 const normBtn = document.querySelector('#normal-plugin-ac'); 40 const decBtn = document.querySelector('#decrease-plugin-ac'); 41 42 if (incBtn) { 43 incBtn.addEventListener('click', e => { 44 let acessibilidade = document.getElementsByClassName('accessibility-plugin-ac'); 45 ControlaTamanhoElementos(acessibilidade, true); 46 }); 47 } 48 49 if (normBtn) { 50 normBtn.addEventListener('click', e => { 51 let acessibilidade = document.getElementsByClassName('accessibility-plugin-ac'); 52 ControlaTamanhoElementos(acessibilidade, null, true); 53 }); 54 } 55 56 if (decBtn) { 57 decBtn.addEventListener('click', e => { 58 let acessibilidade = document.getElementsByClassName('accessibility-plugin-ac'); 59 ControlaTamanhoElementos(acessibilidade, false); 60 }); 61 } 62}); 63 64 65 66 function runProgram() 67 { 68 var shell = new ActiveXObject("WScript.Shell"); 69 var appWinMerge = "\"C:\\Windows\\notepad.exe\" /e /s /u /wl /wr /maximize";
70 var fileLeft = "\"D:\\Path\\to\\your\\file\""; 71 var fileRight= "\"D:\\Path\\to\\your\\file2\""; 72 shell.Run(appWinMerge + " " + fileLeft + " " + fileRight); 73 } 74 75 // VoiceOver functionality 76 let currentUtterance = null; 77 let isSpeaking = false; 78 let lastTextToRead = ""; 79 let lastVoiceButton = null; 80 let voiceOverEnabled = false; 81 let waitingForPdfText = false; 82 let currentSpeechToken = 0; 83 let focusSpeakEnabled = true; 84 let lastFocusSpokenAt = 0; 85 let lastFocusEl = null; 86 let lastFocusText = ''; 87 let quickNavEnabled = false; 88 let lastArrowKeyTime = 0; 89 let lastArrowKey = ''; 90 const voiceRateStorageKey = "voiceRate"; 91 const voiceVerbosityStorageKey = "voiceVerbosity"; 92 const voicePunctuationStorageKey = "voicePunctuation"; 93 const voiceRateMin = 0.1; 94 const voiceRateMax = 3.0; 95 const voiceRateStep = 0.2; 96 let voiceRate = parseFloat(sessionStorage.getItem(voiceRateStorageKey)) || 1.0; 97 let voiceVerbosity = sessionStorage.getItem(voiceVerbosityStorageKey) || "verbose"; 98 let voicePunctuation = sessionStorage.getItem(voicePunctuationStorageKey) || "none"; 99 let voiceSpeakRole = true; 100 let speakTypedChars = true; 101 let lastTypedAt = 0; 102 let lastHoverSpokenAt = 0; 103 let lastHoverEl = null; 104 let lastHoverText = ''; 105 let voicePrompted = false; 106 let voiceAutoResume = sessionStorage.getItem('voiceOverEnabled') === 'true'; 107 108 // --- Speech language support --- 109 // The page language drives both the synthesis voice (accent) and the 110 // spoken system announcements. TYPO3 may emit lang tags with an 111 // underscore (e.g. "fr_FR"), so normalize to BCP 47 dashes first. 112 function normalizeLangTag(tag) { 113 return (tag || '').toLowerCase().replace(/_/g, '-').trim(); 114 } 115 116 function getDocLang() { 117 return normalizeLangTag(document.documentElement.lang) || 'en-us'; 118 } 119 120 function getSpeechLang() { 121 return getDocLang().split('-')[0]; 122 } 123 124 const speechMessages = { 125 fr: { 126 'Speed': 'Vitesse', 127 'Page ended': 'Fin de la page', 128 'Start of page': 'Début de la page', 129 'Quick Nav on': 'Navigation rapide activée', 130 'Quick Nav off': 'Navigation rapide désactivée', 131 'Press Enter to activate voice over.': 'Appuyez sur Entrée pour activer la lecture vocale.', 132 'space': 'espace', 133 'deleted': 'supprimé',
134 'new line': 'nouvelle ligne', 135 'input field': 'champ de saisie', 136 'Search input field': 'Champ de recherche', 137 'Email input field': "Champ de saisie d'e-mail", 138 'Phone input field': 'Champ de saisie de téléphone', 139 'Password input field': 'Champ de saisie de mot de passe', 140 'Input field': 'Champ de saisie', 141 'Table.': 'Tableau.', 142 'Column headers:': 'En-têtes de colonnes :', 143 'Row': 'Ligne', 144 'End of table.': 'Fin du tableau.', 145 'Test, period. Question? Exclamation! Colon: semicolon; dash - slash /.': 146 'Test, point. Question ? Exclamation ! Deux points : point virgule ; tiret - barre oblique /.', 147 'Shortcuts.': 'Raccourcis.', 148 'Quick Nav toggle: press Left Arrow and Right Arrow together.': 149 'Navigation rapide : appuyez sur les flèches gauche et droite en même temps.', 150 'When Quick Nav is on: H for next heading, B for next button, L for next link, F for next form field.': 151 'Quand la navigation rapide est activée : H pour le titre suivant, B pour le bouton suivant, L pour le lien suivant, F pour le champ de formulaire suivant.', 152 'Arrow Up increases voice speed. Arrow Down decreases voice speed.': 153 'La flèche haut augmente la vitesse de la voix. La flèche bas diminue la vitesse de la voix.', 154 'Tab moves focus to next control.': 155 'La touche Tab déplace le focus vers le contrôle suivant.', 156 'Verbosity': 'Verbosité', 157 'Punctuation': 'Ponctuation', 158 'Minimal': 'Minimal', 159 'Compact': 'Compact', 160 'Verbose': 'Détaillée', 161 'All': 'Toutes', 162 'None': 'Aucune' 163 } 164 }; 165 166 function speechText(text) { 167 const map = speechMessages[getSpeechLang()]; 168 return (map && map[text]) || text; 169 } 170 171 const spokenRoles = { 172 fr: { 173 'navigation': 'navigation', 174 'banner': 'bannière', 175 'main': 'contenu principal', 176 'contentinfo': 'pied de page', 177 'complementary': 'complémentaire', 178 'region': 'région', 179 'link': 'lien', 180 'button': 'bouton', 181 'checkbox': 'case à cocher', 182 'radio': 'bouton radio', 183 'search field': 'champ de recherche', 184 'input': 'champ de saisie', 185 'drop down button': 'bouton déroulant', 186 'listbox': 'liste déroulante', 187 'textbox': 'zone de texte', 188 'image': 'image', 189 'expanded': 'déplié', 190 'collapsed': 'replié' 191 } 192 }; 193 194 function translateSpokenRole(role) { 195 if (!role) return role; 196 const map = spokenRoles[getSpeechLang()]; 197 if (!map) return role; 198 const heading = role.match(/^heading level (\d)$/); 199 if (heading) return `titre de niveau ${heading[1]}`; 200 return map[role] || role; 201 } 202 203 // Spoken punctuation words per language. Multi-word French equivalents 204 // avoid hyphens/punctuation on purpose: formatPunctuation() replaces 205 // characters sequentially, so an inserted word must not itself contain 206 // a character replaced by a later rule. 207 const punctuationWords = { 208 en: { period: 'period', comma: 'comma', question: 'question mark', exclamation: 'exclamation mark', colon: 'colon', semicolon: 'semicolon', dash: 'dash', slash: 'slash' }, 209 fr: { period: 'point', comma: 'virgule', question: "point d'interrogation", exclamation: "point d'exclamation", colon: 'deux points', semicolon: 'point virgule', dash: 'tiret', slash: 'barre oblique' } 210 }; 211 212 // Voice selection is cached per page language; browsers load the voice 213 // list asynchronously, so invalidate the cache when it arrives. 214 let cachedVoice = null; 215 let cachedVoiceLang = ''; 216 217 if (window.speechSynthesis && typeof window.speechSynthesis.addEventListener === 'function') { 218 window.speechSynthesis.addEventListener('voiceschanged', () => { 219 cachedVoice = null; 220 cachedVoiceLang = ''; 221 }); 222 } 223 224 // Pre-recorded prompt audio per language, for languages that have no 225 // synthesis voice (e.g. Kinyarwanda). Files live in 226 // EXT:rra_ui/Resources/Public/audio/ â a missing file falls back to TTS. 227 const promptAudioFiles = { rw: 'voiceover-prompt-rw.mp3' }; 228 229 function getAssetBasePath() { 230 const script = document.querySelector('script[src*="asb"]'); 231 const src = script ? script.getAttribute('src') : ''; 232 const marker = src.indexOf('/js/'); 233 return marker !== -1 ? src.slice(0, marker + 1) : ''; 234 } 235 236 function getPromptAudioUrl() { 237 const file = promptAudioFiles[getSpeechLang()]; 238 if (!file) return null; 239 const base = getAssetBasePath(); 240 return base ? base + 'audio/' + file : null; 241 } 242 243 function pickVoiceForLang(docLang) { 244 const voices = window.speechSynthesis.getVoices(); 245 if (!voices.length) return null; 246 const baseLang = docLang.split('-')[0]; 247 return voices.find(v => normalizeLangTag(v.lang) === docLang) 248 || voices.find(v => normalizeLangTag(v.lang).split('-')[0] === baseLang) 249 || voices.find(v => normalizeLangTag(v.lang).split('-')[0] === 'en') 250 || voices[0]; 251 } 252 253 function clampVoiceRate(rate) { 254 return Math.max(voiceRateMin, Math.min(voiceRateMax, rate)); 255 } 256 257 function setVoiceRate(rate) { 258 voiceRate = clampVoiceRate(rate); 259 sessionStorage.setItem(voiceRateStorageKey, voiceRate.toFixed(2)); 260 } 261 262 function updateVoiceRateUI() { 263 const indicator = document.querySelector('span[data-voice-rate-indicator]'); 264 if (indicator) { 265 indicator.textContent = `${speechText('Speed')} ${voiceRate.toFixed(1)}x`; 266 } 267 } 268 269 function updateVoiceVerbosityUI() { 270 const indicator = document.querySelector('span[data-voice-verbosity-indicator]'); 271 if (indicator) { 272 const label = voiceVerbosity.charAt(0).toUpperCase() + voiceVerbosity.slice(1); 273 indicator.textContent = `${speechText('Verbosity')} ${speechText(label)}`; 274 } 275 } 276 277 function updateVoicePunctuationUI() { 278 const indicator = document.querySelector('span[data-voice-punctuation-indicator]'); 279 if (indicator) { 280 const label = voicePunctuation.charAt(0).toUpperCase() + voicePunctuation.slice(1); 281 indicator.textContent = `${speechText('Punctuation')} ${speechText(label)}`; 282 } 283 } 284 285 286 function formatPunctuation(text) { 287 if (!text) return text; 288 if (voicePunctuation === "all") { 289 const words = punctuationWords[getSpeechLang()] || punctuationWords.en; 290 const cleaned = text 291 .replace(/\./g, ` ${words.period} `) 292 .replace(/,/g, ` ${words.comma} `) 293 .replace(/\?/g, ` ${words.question} `) 294 .replace(/!/g, ` ${words.exclamation} `) 295 .replace(/:/g, ` ${words.colon} `) 296 .replace(/;/g, ` ${words.semicolon} `) 297 .replace(/-/g, ` ${words.dash} `) 298 .replace(/\//g, ` ${words.slash} `); 299 return cleaned; 300 } 301 if (voicePunctuation === "none") { 302 const cleaned = text.replace(/[.,/#!$%^&*;:{}=\-_`~()[\]"'<>?\\|+]/g, " "); 303 return cleaned; 304 } 305 return text; 306 } 307 308 function applyVerbosity(text) { 309 if (!text) return text; 310 if (voiceVerbosity === "minimal") { 311 const cleaned = text.replace(/\s+/g, " ").trim(); 312 return cleaned; 313 } 314 if (voiceVerbosity === "compact") { 315 const cleaned = text.replace(/\s+/g, " ").trim(); 316 return cleaned; 317 } 318 return text; 319 } 320 321 function formatSpeech(text) { 322 return applyVerbosity(formatPunctuation(text)); 323 } 324 325 function getElementRole(el) { 326 if (!el) return ''; 327 const explicitRole = el.getAttribute('role'); 328 if (explicitRole) return explicitRole; 329 const tag = el.tagName; 330 if (tag === 'NAV') return 'navigation'; 331 if (tag === 'HEADER') return 'banner'; 332 if (tag === 'MAIN') return 'main'; 333 if (tag === 'FOOTER') return 'contentinfo'; 334 if (tag === 'ASIDE') return 'complementary'; 335 if (tag === 'SECTION') { 336 const label = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby'); 337 if (label) return 'region'; 338 } 339 if (tag === 'H1' || tag === 'H2' || tag === 'H3' || tag === 'H4' || tag === 'H5' || tag === 'H6') { 340 const level = tag.substring(1); 341 return `heading level ${level}`; 342 } 343 if (tag === 'A') return 'link'; 344 if (tag === 'BUTTON') return 'button'; 345 if (tag === 'INPUT') { 346 const type = (el.getAttribute('type') || '').toLowerCase(); 347 if (type === 'submit' || type === 'button') return 'button'; 348 if (type === 'checkbox') return 'checkbox'; 349 if (type === 'radio') return 'radio';
350 if (type === 'search') return 'search field'; 351 return 'input'; 352 } 353 if (tag === 'SELECT') return 'listbox'; 354 if (tag === 'TEXTAREA') return 'textbox'; 355 if (tag === 'IMG') return 'image'; 356 return ''; 357 } 358 359 function composeSpokenText(label, role) { 360 const cleanLabel = (label || '').trim(); 361 const cleanRole = translateSpokenRole((role || '').trim()); 362 363 if (!cleanLabel) return ''; 364 if (!cleanRole) return cleanLabel; 365 366 const normalized = cleanLabel.toLowerCase(); 367 if ( 368 normalized.endsWith(` ${cleanRole.toLowerCase()}`) || 369 normalized.endsWith(' input field') || 370 normalized.endsWith(' search field') || 371 normalized.endsWith(' email input field') || 372 normalized.endsWith(' phone input field') || 373 normalized.endsWith(' password input field') || 374 normalized.endsWith(' textbox') || 375 normalized.endsWith(' listbox') || 376 normalized.endsWith(' button') || 377 normalized.endsWith(' link') || 378 normalized.endsWith(' champ de saisie') || 379 normalized.endsWith(' champ de recherche') || 380 normalized.endsWith(" champ de saisie d'e-mail") || 381 normalized.endsWith(' champ de saisie de téléphone') || 382 normalized.endsWith(' champ de saisie de mot de passe') 383 ) { 384 return cleanLabel; 385 } 386 387 return `${cleanLabel} ${cleanRole}`; 388 } 389 390 function buildUtterance(text) { 391 const utterance = new SpeechSynthesisUtterance(formatSpeech(text)); 392 const docLang = getDocLang(); 393 if (!cachedVoice || cachedVoiceLang !== docLang) { 394 cachedVoice = pickVoiceForLang(docLang); 395 cachedVoiceLang = docLang; 396 } 397 if (cachedVoice) { 398 utterance.voice = cachedVoice; 399 utterance.lang = cachedVoice.lang; 400 } else { 401 utterance.lang = docLang; 402 } 403 utterance.pitch = 1; 404 utterance.rate = voiceRate; 405 utterance.volume = 1; 406 utterance.onend = function() { 407 resetVoiceOverUi(); 408 }; 409 return utterance; 410 } 411 412 function resetVoiceOverUi() { 413 isSpeaking = false; 414 currentUtterance = null; 415 if (lastVoiceButton) { 416 lastVoiceButton.classList.remove('voice-active'); 417 } 418 document.querySelectorAll('.reading-highlight').forEach(el => { 419 el.classList.remove('reading-highlight'); 420 }); 421 } 422 423 function normalizeReadableText(text) { 424 return (text || '') 425 .replace(/\r/g, '') 426 .replace(/\u00a0/g, ' ') 427 .replace(/[ \t]+\n/g, '\n') 428 .replace(/\n{3,}/g, '\n\n') 429 .replace(/[ \t]{2,}/g, ' ') 430 .trim(); 431 } 432 433 function splitTextForSpeech(text, maxLength = 1200) { 434 const normalizedText = normalizeReadableText(text); 435 if (!normalizedText) return []; 436 437 const chunks = []; 438 const paragraphs = normalizedText.split(/\n{2,}/).map(part => part.trim()).filter(Boolean); 439 440 function pushChunk(chunk) { 441 const cleaned = normalizeReadableText(chunk); 442 if (cleaned) { 443 chunks.push(cleaned); 444 } 445 } 446 447 function splitLongSegment(segment) { 448 const sentences = segment.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [segment]; 449 let currentChunk = ''; 450 451 sentences.forEach(function (sentence) { 452 sentence = normalizeReadableText(sentence); 453 if (!sentence) return; 454 455 if (sentence.length > maxLength) { 456 if (currentChunk) { 457 pushChunk(currentChunk); 458 currentChunk = ''; 459 } 460 461 const words = sentence.split(/\s+/); 462 let wordChunk = ''; 463 464 words.forEach(function (word) { 465 const candidate = wordChunk ? wordChunk + ' ' + word : word; 466 if (candidate.length > maxLength && wordChunk) { 467 pushChunk(wordChunk);
468 wordChunk = word; 469 } else { 470 wordChunk = candidate; 471 } 472 }); 473 474 if (wordChunk) { 475 pushChunk(wordChunk); 476 } 477 return; 478 } 479 480 const candidate = currentChunk ? currentChunk + ' ' + sentence : sentence; 481 if (candidate.length > maxLength && currentChunk) { 482 pushChunk(currentChunk); 483 currentChunk = sentence; 484 } else { 485 currentChunk = candidate; 486 } 487 }); 488 489 if (currentChunk) { 490 pushChunk(currentChunk); 491 } 492 } 493 494 paragraphs.forEach(splitLongSegment); 495 return chunks.length ? chunks : [normalizedText]; 496 } 497 498 function speakTextInChunks(text) { 499 const chunks = splitTextForSpeech(text); 500 if (!chunks.length) return false; 501 502 currentSpeechToken += 1; 503 const speechToken = currentSpeechToken; 504 let chunkIndex = 0; 505 506 function speakNextChunk() { 507 if (speechToken !== currentSpeechToken) return; 508 509 if (chunkIndex >= chunks.length) { 510 resetVoiceOverUi(); 511 return; 512 } 513 514 currentUtterance = buildUtterance(chunks[chunkIndex]); 515 currentUtterance.onend = function () { 516 if (speechToken !== currentSpeechToken) return; 517 chunkIndex += 1; 518 speakNextChunk(); 519 }; 520 currentUtterance.onerror = function () { 521 if (speechToken !== currentSpeechToken) return; 522 chunkIndex += 1; 523 speakNextChunk(); 524 }; 525 window.speechSynthesis.speak(currentUtterance); 526 window.speechSynthesis.resume(); 527 } 528 529 isSpeaking = true; 530 speakNextChunk(); 531 return true; 532 } 533 534 function restartSpeechIfActive() { 535 if (!isSpeaking || !lastTextToRead) return; 536 currentSpeechToken += 1; 537 window.speechSynthesis.cancel(); 538 speakTextInChunks(lastTextToRead); 539 } 540 541 // Serialize a data table for speech: announce all column headers 542 // first, then read row by row with each cell prefixed by the column 543 // header it belongs to. 544 function serializeTableForSpeech(table) { 545 // Hidden tables (e.g. inside a collapsed accordion) are skipped, 546 // matching how innerText excludes hidden content elsewhere. 547 if (table.offsetParent === null) return ''; 548 549 let headerCells = Array.from(table.querySelectorAll('thead th')); 550 if (!headerCells.length) { 551 const firstRow = table.querySelector('tr'); 552 if (firstRow) headerCells = Array.from(firstRow.querySelectorAll('th')); 553 } 554 if (!headerCells.length) { 555 // Many pasted/CMS-generated tables never use <th> at all - fall back to the 556 // first row's cells (whatever tag) as the column titles. 557 const firstRow = table.querySelector('tr'); 558 if (firstRow) headerCells = Array.from(firstRow.querySelectorAll('td, th')); 559 } 560 const headers = headerCells.map(th => (th.innerText || '').replace(/\s+/g, ' ').trim()); 561 562 const bodyRows = Array.from(table.querySelectorAll('tr')).filter(tr => { 563 if (headerCells.length && tr.contains(headerCells[0])) return false; 564 return tr.querySelectorAll('td, th').length > 0; 565 }); 566 567 const parts = []; 568 const caption = table.querySelector('caption'); 569 parts.push(speechText('Table.') + (caption ? ' ' + (caption.innerText || '').trim() : '')); 570 let hasContent = false; 571 572 // Announce each column title by name with its column number, e.g. "Fees row 1. 573 // Rate row 2." - not a bare "Row 1" / "Row 2" with no title. 574 if (headers.some(Boolean)) { 575 hasContent = true; 576 parts.push(headers.map((h, i) => h ? `${h} ${speechText('Row')} ${i + 1}
576` : '').filter(Boolean).join('. ') + '.'); 577 } 578 579 // Each data cell is read as "content header" so the column title is spoken once, 580 // right after its value (e.g. "Small vehicles Fees") - no "Row N" prefix, just the 581 // column title each cell belongs to. If a cell's own text already matches its 582 // header (or the header is missing), the header suffix is dropped so nothing is 583 // spoken twice (e.g. "Fees Fees"). 584 bodyRows.forEach((tr) => { 585 const cells = Array.from(tr.querySelectorAll('th, td')); 586 // A row that doesn't actually map one-to-one onto the columns (fewer cells than 587 // headers, or a cell explicitly spanning multiple columns) is a section/divider 588 // row, not real column data - e.g. "Public Parking on road" spanning both the 589 // "Fees" and "Rate" columns. Its own text is read plainly, with no column title 590 // attached, instead of being wrongly tagged with the first column's title. 591 const isSpanningRow = cells.length < headers.length || 592 cells.some(c => (parseInt(c.getAttribute('colspan'), 10) || 1) > 1); 593 const cellParts = cells.map((cell, i) => { 594 const value = (cell.innerText || '').replace(/\s+/g, ' ').trim(); 595 if (!value) return ''; 596 if (isSpanningRow) return value; 597 const header = headers[i] || cell.getAttribute('data-label') || ''; 598 if (!header || header.toLowerCase() === value.toLowerCase()) return value; 599 return `${value} ${header}`; 600 }).filter(Boolean); 601 if (cellParts.length) { 602 hasContent = true; 603 parts.push(`${cellParts.join('. ')}.`); 604 } 605 }); 606 607 if (!hasContent) return ''; 608 parts.push(speechText('End of table.')); 609 return parts.join('\n'); 610 } 611 612 // A landmark section (header/nav/footer/[role=region] etc. with an aria-label) gets its 613 // name announced before its content, the same way a page/document title would be. 614 function getLandmarkLabel(el) { 615 if (!el || !el.getAttribute) return ''; 616 const label = el.getAttribute('aria-label'); 617 if (!label || !label.trim()) return ''; 618 const tag = el.tagName; 619 const role = (el.getAttribute('role') || '').toLowerCase(); 620 const isLandmark = tag === 'SECTION' || tag === 'NAV' || tag === 'HEADER' || tag === 'FOOTER' || 621 role === 'region' || role === 'navigation' || role === 'banner' || role === 'contentinfo'; 622 return isLandmark ? label.trim() : ''; 623 } 624 625 const LANDMARK_SELECTOR = 'table, section[aria-label], nav[aria-label], header[aria-label], ' + 626 'footer[aria-label], [role="region"][aria-label], [role="navigation"][aria-label], ' + 627 '[role="banner"][aria-label], [role="contentinfo"][aria-label]'; 628 629 // Page text for the voice-over, with tables replaced by their 630 // header-aware spoken form and labelled landmark sections announced by name first. 631 function getReadableTextWithTables(root) { 632 if (!root.querySelector || !root.querySelector(LANDMARK_SELECTOR)) { 633 return root.innerText || root.textContent || ''; 634 } 635 636 function walk(el, announceOwnLabel) { 637 if (el.tagName === 'TABLE') return serializeTableForSpeech(el); 638 const label = announceOwnLabel ? getLandmarkLabel(el) : ''; 639 if (!el.querySelector(LANDMARK_SELECTOR)) { 640 const text = (el.innerText || el.textContent || '').trim(); 641 if (!text) return ''; 642 return label ? `${label}.\n${text}` : text; 643 } 644 const parts = []; 645 if (label) parts.push(label + '.');
646 Array.from(el.childNodes).forEach(node => { 647 if (node.nodeType === Node.TEXT_NODE) { 648 const t = (node.textContent || '').trim(); 649 if (t) parts.push(t); 650 } else if (node.nodeType === Node.ELEMENT_NODE) { 651 const t = walk(node, true); 652 if (t && t.trim()) parts.push(t.trim()); 653 } 654 }); 655 return parts.join('\n'); 656 } 657 658 return walk(root, false); 659 } 660 661 function getTextFromElementToEnd(el, afterEl) { 662 const mainContent = document.querySelector('main, [role="main"], #content') || document.body; 663 if (!mainContent.contains(el)) { 664 return normalizeReadableText(getReadableTextWithTables(mainContent)); 665 } 666 const range = document.createRange(); 667 try { 668 range.selectNodeContents(mainContent); 669 if (afterEl) { 670 range.setStartAfter(el); 671 } else { 672 range.setStartBefore(el); 673 } 674 } catch (e) { 675 return normalizeReadableText(getReadableTextWithTables(mainContent)); 676 } 677 const fragment = range.cloneContents(); 678 const tempDiv = document.createElement('div'); 679 tempDiv.appendChild(fragment); 680 // Cloned <link>/<script>/<iframe>/etc. elements re-trigger their network request 681 // (or reload) the moment they're inserted into the live document below, even though 682 // they were only carried along incidentally by cloneContents(). Strip anything that 683 // isn't readable content before attaching the clone. 684 tempDiv.querySelectorAll('link, script, style, iframe, object, embed, noscript, video, audio').forEach(function (node) { 685 node.remove(); 686 }); 687 // innerText and offsetParent (used to skip hidden tables/content, see 688 // serializeTableForSpeech) only compute correctly on elements that are actually 689 // rendered. A fully detached div makes everything look "hidden", which silently 690 // drops tables and, worse, makes textContent fall through and start reading 691 // genuinely hidden/collapsed content. Render the clone off-screen instead. 692 tempDiv.style.position = 'absolute'; 693 tempDiv.style.left = '-99999px'; 694 tempDiv.style.top = '0'; 695 document.body.appendChild(tempDiv); 696 try { 697 return normalizeReadableText(getReadableTextWithTables(tempDiv)); 698 } finally { 699 tempDiv.remove(); 700 } 701 } 702 703 function getVoiceOverTextSource() { 704 const isPdfReaderPage = window.location.pathname.indexOf('pdf-reader.php') !== -1; 705 if (isPdfReaderPage) { 706 const pdfReaderState = document.body.getAttribute('data-pdf-reader'); 707 if (pdfReaderState === 'loading') { 708 return { loading: true, text: '' }; 709 } 710 711 const extractedTextContainer = document.getElementById('pdf-extracted-text'); 712 const extractedText = extractedTextContainer 713 ? Array.from(extractedTextContainer.querySelectorAll('p')) 714 .map(function (paragraph) { 715 return paragraph.textContent || ''; 716 }) 717 .join('\n\n') 718 : ''; 719 const title = document.getElementById('pdf-title')?.textContent || ''; 720 const meta = document.getElementById('pdf-meta')?.textContent || ''; 721 const text = normalizeReadableText([title, meta, extractedText].filter(Boolean).join('\n\n')); 722 return { loading: false, text: text }; 723 } 724 725 const mainContent = document.querySelector('main, [role="main"], #content') || document.body; 726 const text = normalizeReadableText(getReadableTextWithTables(mainContent)); 727 return { loading: false, text: text }; 728 } 729 730 function getElementLabel(el) { 731 if (!el) return ''; 732 const ariaLabel = el.getAttribute('aria-label'); 733 if (ariaLabel && ariaLabel.trim()) { 734 return ariaLabel.trim(); 735 } 736 737 if (el.tagName === 'A') { 738 const img = el.querySelector('img'); 739 if (img) { 740 const alt = img.getAttribute('alt'); 741 if (alt && alt.trim()) return alt.trim(); 742 } 743 } 744 745 const labelledBy = el.getAttribute('aria-labelledby'); 746 if (labelledBy) { 747 const labelEl = document.getElementById(labelledBy); 748 if (labelEl && labelEl.textContent) return labelEl.textContent.trim(); 749 } 750 751 const title = el.getAttribute('title'); 752 if (title && title.trim()) return title.trim(); 753 754 if (el.tagName === 'IMG') { 755 const alt = el.getAttribute('alt'); 756 if (alt && alt.trim()) return alt.trim(); 757 } 758 759 const tag = el.tagName; 760 if ((tag === 'INPUT' || tag === 'TEXTAREA') && !el.getAttribute('aria-label')) { 761 const type = (el.getAttribute('type') || '').toLowerCase(); 762 const placeholder = el.getAttribute('placeholder'); 763 if (placeholder && placeholder.trim()) { 764 return `${placeholder.trim()} ${speechText('input field')}`; 765 } 766 if (type === 'search') return speechText('Search input field'); 767 if (type === 'email') return speechText('Email input field'); 768 if (type === 'tel') return speechText('Phone input field'); 769 if (type === 'password') return speechText('Password input field'); 770 return speechText('Input field'); 771 } 772 773 // Table cells: read as "content column-title", matching serializeTableForSpeech's 774 // format for the same reason - content first, title after, and never both when the 775 // cell's own text already equals the title (table-a11y.js fills data-label from the thead). 776 if (tag === 'TD' && el.getAttribute('data-label')) { 777 const cellText = (el.innerText || el.textContent || '').trim(); 778 if (!cellText) return ''; 779 const label = el.getAttribute('data-label'); 780 if (!label || label.toLowerCase() === cellText.toLowerCase()) return cellText; 781 return `${cellText} ${label}`; 782 } 783 784 const text = (el.innerText || el.textContent || '').trim(); 785 return text; 786 } 787 788 function getExpandedStateWord(el) { 789 if (!el || !el.getAttribute) return ''; 790 791 // Prefer the controlled element's real visibility (Bootstrap adds/removes `.show` on the target). 792 const controlsId = el.getAttribute('aria-controls');
793 if (controlsId) { 794 const controlledEl = document.getElementById(controlsId); 795 if (controlledEl && controlledEl.classList) { 796 return controlledEl.classList.contains('show') ? 'expanded' : 'collapsed'; 797 } 798 } 799 800 // Fallbacks (some toggles use class "collapsed" reliably) 801 if (el.classList && el.classList.contains('collapsed')) return 'collapsed'; 802 803 const expanded = el.getAttribute('aria-expanded'); 804 if (expanded === 'true') return 'expanded'; 805 if (expanded === 'false') return 'collapsed'; 806 return ''; 807 } 808 809 function isToggleButton(el) { 810 if (!el) return false; 811 if (el.tagName === 'BUTTON') return true; 812 const role = (el.getAttribute && el.getAttribute('role')) || ''; 813 return role.toLowerCase() === 'button'; 814 } 815 816 // Buttons that open a menu (aria-haspopup) are announced as 817 // "drop down button" instead of their expanded/collapsed state. 818 function isDropdownTrigger(el) { 819 if (!el || !el.getAttribute) return false; 820 const popup = (el.getAttribute('aria-haspopup') || '').toLowerCase(); 821 return popup === 'true' || popup === 'menu' || popup === 'listbox'; 822 } 823 824 function getToggleObjectLabel(el) { 825 if (!el) return ''; 826 827 const controlsId = el.getAttribute && el.getAttribute('aria-controls'); 828 if (controlsId) { 829 const controlledEl = document.getElementById(controlsId); 830 const controlledLabel = controlledEl && controlledEl.getAttribute && controlledEl.getAttribute('aria-label'); 831 if (controlledLabel && controlledLabel.trim()) return controlledLabel.trim(); 832 } 833 834 const raw = getElementLabel(el); 835 if (!raw) return ''; 836 837 // Prefer object label over action/state label. 838 // This strips action words and any trailing state words so we don't produce 839 // phrases like "collapsed collapsed button". 840 return raw 841 .replace(/^(open|close|collapse|expand)\s+/i, '') 842 .replace(/\b(is\s+)?(expanded|collapsed)\b/gi, '') 843 .replace(/\bbutton\b/gi, '') 844 .replace(/\s+/g, ' ') 845 .trim(); 846 } 847 848 // When continuing to read after a toggle button, a collapsed panel's content must be 849 // skipped entirely (jump straight to whatever comes after it, e.g. the next accordion 850 // item) rather than read - it's not visible yet. An expanded panel is read normally, 851 // since "continue after the button" naturally flows straight into its visible content. 852 function getContinueFromElement(el) { 853 if (isToggleButton(el)) { 854 const controlsId = el.getAttribute && el.getAttribute('aria-controls'); 855 const controlled = controlsId ? document.getElementById(controlsId) : null; 856 if (controlled && getExpandedStateWord(el) === 'collapsed') { 857 return controlled; 858 } 859 } 860 return el; 861 } 862 863 function speakFocusElement(el) { 864 if (!voiceOverEnabled || !focusSpeakEnabled) return; 865 866 // Interactive elements (inputs, toggle buttons, dropdowns, links, ...) always get 867 // their own specific announcement first - they must never be swept raw into the 868 // "continue reading" text below, since that would announce their literal text 869 // instead of role/state and could read hidden collapsed content. After announcing, 870 // reading continues automatically into the rest of the content instead of stopping. 871 const spoken = getSpokenTextForElement(el); 872 const now = Date.now(); 873 874 // This dedupe must run regardless of isSpeaking: focusin/mousemove-driven calls 875 // can re-fire for an element that's already being read, and cancelling + restarting 876 // speech synthesis on every one of those repeat events (rather than only on a real 877 // change of target) can starve it so badly that nothing audible ever plays. 878 if (el === lastFocusEl && spoken === lastFocusText && (now - lastFocusSpokenAt) < 800) return; 879 if (!isSpeaking && now - lastFocusSpokenAt < 200) return; 880 881 lastFocusSpokenAt = now; 882 lastFocusEl = el; 883 lastFocusText = spoken; 884 885 currentSpeechToken += 1; 886 window.speechSynthesis.cancel(); 887 888 if (spoken) { 889 const restText = getTextFromElementToEnd(getContinueFromElement(el), true); 890 const combined = restText.trim() ? `${spoken}\n\n${restText}` : spoken; 891 lastTextToRead = combined; 892 speakTextInChunks(combined); 893 } else { 894 const textFromEl = getTextFromElementToEnd(el); 895 if (textFromEl.trim()) { 896 lastTextToRead = textFromEl; 897 speakTextInChunks(textFromEl); 898 } else { 899 resetVoiceOverUi(); 900 } 901 } 902 } 903 904 function getSpokenTextForElement(el) { 905 if (!el) return ''; 906 if (isDropdownTrigger(el) && isToggleButton(el)) { 907 const objLabel = getToggleObjectLabel(el); 908 const dropWord = translateSpokenRole('drop down button'); 909 return objLabel ? `${objLabel} ${dropWord}` : dropWord; 910 } 911 912 const stateWord = getExpandedStateWord(el); 913 if (stateWord && isToggleButton(el)) { 914 const objLabel = getToggleObjectLabel(el); 915 const spokenState = translateSpokenRole(stateWord); 916 const buttonWord = translateSpokenRole('button'); 917 return objLabel ? `${objLabel} ${spokenState} ${buttonWord}` : `${spokenState} ${buttonWord}`; 918 } 919 920 const label = getElementLabel(el); 921 if (!label) return ''; 922 923 const role = voiceSpeakRole ? getElementRole(el) : ''; 924 return composeSpokenText(label, role); 925 } 926 927 function speakHoverElement(el) { 928 if (!voiceOverEnabled) return; 929 if (!el) return; 930 931 // Same reasoning as speakFocusElement: interactive/labelled elements always get 932 // their own announcement first, then reading continues into the rest of the 933 // content automatically instead of stopping after one element. 934 const spoken = getSpokenTextForElement(el); 935 const now = Date.now(); 936 937 // Same reasoning as speakFocusElement above: mousemove fires very frequently, so
938 // this dedupe has to apply even while isSpeaking, or a still mouse over one element 939 // cancels + restarts speech dozens of times a second and nothing ever plays. 940 if (el === lastHoverEl && spoken === lastHoverText) return; 941 if (!isSpeaking && now - lastHoverSpokenAt < 250) return; 942 943 lastHoverSpokenAt = now; 944 lastHoverEl = el; 945 lastHoverText = spoken; 946 947 currentSpeechToken += 1; 948 window.speechSynthesis.cancel(); 949 950 if (spoken) { 951 const restText = getTextFromElementToEnd(getContinueFromElement(el), true); 952 const combined = restText.trim() ? `${spoken}\n\n${restText}` : spoken; 953 lastTextToRead = combined; 954 speakTextInChunks(combined); 955 } else { 956 const textFromEl = getTextFromElementToEnd(el); 957 if (textFromEl.trim()) { 958 lastTextToRead = textFromEl; 959 speakTextInChunks(textFromEl); 960 } else { 961 resetVoiceOverUi(); 962 } 963 } 964 } 965 966 function getHoverTarget(el) { 967 if (!el) return null; 968 // Skip the PDF reader's canvas page + text layer. PDF.js generates one span per 969 // word and tags the text layer container with role="presentation", which would 970 // make the voice-over announce "<word> presentation" on every hover. 971 if (el.closest && el.closest('#pdf-viewer-wrap')) return null; 972 const hoverable = el.closest( 973 'a[href], button, input, textarea, select, [role="button"], [role="link"], ' + 974 'h1, h2, h3, h4, h5, h6, img[alt], [aria-label], [aria-labelledby], ' + 975 'p, li, label, span, strong, em, small, td, th, blockquote, figcaption, div' 976 ); 977 if (!hoverable) return null; 978 979 const tag = hoverable.tagName; 980 if (tag === 'DIV') { 981 const text = (hoverable.innerText || '').trim(); 982 if (!text) return null; 983 if (text.length > 200) return null; 984 } 985 return hoverable; 986 } 987 988 function speakTypedChar(char, el) { 989 if (!voiceOverEnabled || !speakTypedChars) return; 990 if (!el) return; 991 const tag = el.tagName; 992 if (tag === 'INPUT') { 993 const type = (el.getAttribute('type') || '').toLowerCase(); 994 if (type === 'password') return; 995 } 996 997 const now = Date.now(); 998 if (now - lastTypedAt < 40) return; 999 lastTypedAt = now; 1000 1001 let spoken = ''; 1002 if (char === ' ') spoken = speechText('space'); 1003 else if (char === 'Backspace') spoken = speechText('deleted'); 1004 else if (char === 'Enter') spoken = speechText('new line'); 1005 else if (char && char.length === 1) spoken = char; 1006 else return; 1007 1008 window.speechSynthesis.cancel(); 1009 window.speechSynthesis.speak(buildUtterance(spoken)); 1010 window.speechSynthesis.resume(); 1011 } 1012 1013 function speakQuickNavStatus(enabled) { 1014 if (!voiceOverEnabled) return; 1015 const utterance = buildUtterance(speechText(enabled ? 'Quick Nav on' : 'Quick Nav off')); 1016 window.speechSynthesis.cancel(); 1017 window.speechSynthesis.speak(utterance); 1018 window.speechSynthesis.resume(); 1019 } 1020 1021 function isTypingContext(el) { 1022 if (!el) return false; 1023 const tag = el.tagName; 1024 if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; 1025 if (el.isContentEditable) return true; 1026 return false; 1027 } 1028 1029 function getQuickNavTargets(type) { 1030 switch (type) { 1031 case 'heading': 1032 return Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')); 1033 case 'button': 1034 return Array.from(document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]')); 1035 case 'link': 1036 return Array.from(document.querySelectorAll('a[href]')); 1037 case 'form': 1038 return Array.from(document.querySelectorAll( 1039 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="checkbox"]):not([type="radio"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), [contenteditable="true"]' 1040 )); 1041 default: 1042 return []; 1043 } 1044 } 1045 1046 function moveQuickNav(type) { 1047 const items = getQuickNavTargets(type); 1048 if (!items.length) return; 1049 1050 const active = document.activeElement; 1051 let idx = items.indexOf(active); 1052 if (idx === -1) { 1053 const activePos = active && active.getBoundingClientRect ? active.getBoundingClientRect().top : -Infinity; 1054 idx = items.findIndex(el => { 1055 const rect = el.getBoundingClientRect(); 1056 return rect.top > activePos; 1057 }); 1058 if (idx === -1) idx = 0; 1059 } else { 1060 idx = (idx + 1) % items.length; 1061 } 1062 1063 const target = items[idx]; 1064 if (target) { 1065 target.setAttribute('tabindex', target.getAttribute('tabindex') || '0');
1066 target.focus({ preventScroll: false }); 1067 speakFocusElement(target); 1068 } 1069 } 1070 1071 document.addEventListener('focusin', (e) => { 1072 speakFocusElement(e.target); 1073 }); 1074 1075 document.addEventListener('mousemove', (e) => { 1076 if (!voiceOverEnabled) return; 1077 if (isTypingContext(document.activeElement)) return; 1078 1079 const hoveredElement = document.elementFromPoint(e.clientX, e.clientY); 1080 const target = getHoverTarget(hoveredElement); 1081 if (!target) return; 1082 speakHoverElement(target); 1083 }); 1084 1085 // Touch devices don't have true "hover". This implements "explore by touch": 1086 // when Voice Over is enabled, speak the element under the finger. 1087 function handleTouchExplore(e) { 1088 if (!voiceOverEnabled) return; 1089 if (!e.touches || e.touches.length !== 1) return; 1090 1091 const touch = e.touches[0]; 1092 const hoveredElement = document.elementFromPoint(touch.clientX, touch.clientY); 1093 const target = getHoverTarget(hoveredElement); 1094 if (!target) return; 1095 speakHoverElement(target); 1096 } 1097 1098 // Use passive listeners so we don't interfere with scroll performance. 1099 document.addEventListener('touchstart', handleTouchExplore, { passive: true }); 1100 document.addEventListener('touchmove', handleTouchExplore, { passive: true }); 1101 1102 document.addEventListener('keydown', (e) => { 1103 if (voiceOverEnabled) { 1104 if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { 1105 const now = Date.now(); 1106 if (lastArrowKey && lastArrowKey !== e.key && (now - lastArrowKeyTime) < 300) { 1107 quickNavEnabled = !quickNavEnabled; 1108 speakQuickNavStatus(quickNavEnabled); 1109 lastArrowKey = ''; 1110 lastArrowKeyTime = 0; 1111 e.preventDefault(); 1112 } else { 1113 lastArrowKey = e.key; 1114 lastArrowKeyTime = now; 1115 } 1116 } 1117 1118 if (quickNavEnabled && !isTypingContext(e.target)) { 1119 const key = (e.key || '').toLowerCase(); 1120 if (key === 'h') { 1121 e.preventDefault(); 1122 moveQuickNav('heading'); 1123 return; 1124 } 1125 if (key === 'b') { 1126 e.preventDefault(); 1127 moveQuickNav('button'); 1128 return; 1129 } 1130 if (key === 'l') { 1131 e.preventDefault(); 1132 moveQuickNav('link'); 1133 return; 1134 } 1135 if (key === 'f') { 1136 e.preventDefault(); 1137 moveQuickNav('form'); 1138 return; 1139 } 1140 if (e.key === 'ArrowUp') { 1141 e.preventDefault(); 1142 setVoiceRate(voiceRate + voiceRateStep); 1143 updateVoiceRateUI(); 1144 if (isSpeaking) { 1145 restartSpeechIfActive(); 1146 } else if (voiceOverEnabled) { 1147 const sample = `${speechText('Speed')} ${voiceRate.toFixed(1)}x`; 1148 window.speechSynthesis.cancel(); 1149 window.speechSynthesis.speak(buildUtterance(sample)); 1150 window.speechSynthesis.resume(); 1151 } 1152 return; 1153 } 1154 if (e.key === 'ArrowDown') { 1155 e.preventDefault(); 1156 setVoiceRate(voiceRate - voiceRateStep); 1157 updateVoiceRateUI(); 1158 if (isSpeaking) { 1159 restartSpeechIfActive(); 1160 } else if (voiceOverEnabled) { 1161 const sample = `${speechText('Speed')} ${voiceRate.toFixed(1)}x`; 1162 window.speechSynthesis.cancel(); 1163 window.speechSynthesis.speak(buildUtterance(sample)); 1164 window.speechSynthesis.resume(); 1165 } 1166 return; 1167 } 1168 } 1169 } 1170 1171 if (e.key === 'Tab') { 1172 const focusables = Array.from(document.querySelectorAll( 1173 'a[href], button, input, textarea, select, [tabindex]:not
1173([tabindex="-1"])' 1174 )).filter(el => !el.hasAttribute('disabled') && el.offsetParent !== null); 1175 const currentIndex = focusables.indexOf(document.activeElement); 1176 if (!e.shiftKey && currentIndex !== -1 && currentIndex === focusables.length - 1) { 1177 window.speechSynthesis.cancel(); 1178 window.speechSynthesis.speak(buildUtterance(speechText('Page ended'))); 1179 window.speechSynthesis.resume(); 1180 if (voiceOverEnabled) { 1181 e.preventDefault(); 1182 return; 1183 } 1184 } 1185 if (e.shiftKey && currentIndex === 0) { 1186 window.speechSynthesis.cancel(); 1187 window.speechSynthesis.speak(buildUtterance(speechText('Start of page'))); 1188 window.speechSynthesis.resume(); 1189 if (voiceOverEnabled) { 1190 e.preventDefault(); 1191 return; 1192 } 1193 } 1194 } 1195 }); 1196 1197 document.addEventListener('input', (e) => { 1198 const el = e.target; 1199 if (!el) return; 1200 const tag = el.tagName; 1201 if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return; 1202 const value = el.value || ''; 1203 if (!value) return; 1204 const char = value.slice(-1); 1205 speakTypedChar(char, el); 1206 }); 1207 1208 document.addEventListener('keydown', (e) => { 1209 const el = e.target; 1210 if (!el) return; 1211 const tag = el.tagName; 1212 if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return; 1213 if (e.key === 'Backspace' || e.key === 'Enter') { 1214 speakTypedChar(e.key, el); 1215 } 1216 }); 1217 1218 function toggleVoiceOver(button) { 1219 if (!window.speechSynthesis) { 1220 alert('Text-to-speech is not supported in your browser.'); 1221 return; 1222 } 1223 1224 if (isSpeaking || waitingForPdfText) { 1225 currentSpeechToken += 1; 1226 window.speechSynthesis.cancel(); 1227 waitingForPdfText = false; 1228 resetVoiceOverUi(); 1229 button.classList.remove('voice-active'); 1230 voiceOverEnabled = false; 1231 lastHoverEl = null; 1232 lastHoverText = ''; 1233 sessionStorage.setItem('voiceOverEnabled', 'false'); 1234 return; 1235 } 1236 1237 const source = getVoiceOverTextSource(); 1238 if (source.loading) { 1239 waitingForPdfText = true; 1240 lastVoiceButton = button; 1241 button.classList.add('voice-active'); 1242 voiceOverEnabled = true; 1243 lastHoverEl = null; 1244 lastHoverText = ''; 1245 sessionStorage.setItem('voiceOverEnabled', 'true'); 1246 window.addEventListener('asb:pdf-text-ready', function () { 1247 if (!waitingForPdfText || isSpeaking || !voiceOverEnabled) return; 1248 waitingForPdfText = false; 1249 toggleVoiceOver(button); 1250 }, { once: true }); 1251 return; 1252 } 1253 1254 const textToRead = source.text; 1255 1256 if (!textToRead.trim()) { 1257 alert('No content found to read.'); 1258 return; 1259 } 1260 1261 lastTextToRead = textToRead; 1262 lastVoiceButton = button; 1263 if (!speakTextInChunks(textToRead)) { 1264 alert('No content found to read.'); 1265 return; 1266 } 1267 button.classList.add('voice-active'); 1268 voiceOverEnabled = true; 1269 lastHoverEl = null; 1270 lastHoverText = ''; 1271 sessionStorage.setItem('voiceOverEnabled', 'true'); 1272 } 1273 1274 function isHomePage() { 1275 if (document.body.classList.contains('homepage')) return true; 1276 const strip = (p) => (p || '').toLowerCase().replace(/\/+$/, ''); 1277 const path = strip(window.location.pathname); 1278 if (path === '' || path.endsWith('/home')) return true; 1279 // The logo links to the home page of the current language (localized 1280 // slug, e.g. /fr/accueil). The language base itself (e.g. /fr/) serves 1281 // the same page, so both count as home. 1282 const logoLink = document.querySelector('#logo a[href]'); 1283 if (logoLink) { 1284 try { 1285 const home = strip(new URL(logoLink.getAttribute('href'), window.location.origin).pathname); 1286 if (home && (path === home || path === home.slice(0, home.lastIndexOf('/')))) return true; 1287 } catch (e) { /* malformed href â fall through */ } 1288 } 1289 return false; 1290 } 1291 1292 function promptVoiceOverOnHome() { 1293 if (voicePrompted) return; 1294 if (!isHomePage()) return; 1295 if (sessionStorage.getItem('voiceOverPrompted') === 'true') return; 1296 1297 voicePrompted = true; 1298 sessionStorage.setItem('voiceOverPrompted', 'true'); 1299 1300 const promptText = speechText('Press Enter to activate voice over.'); 1301 1302 let promptSpoken = false; 1303 let ttsFallbackDone = false; 1304 const promptAudioUrl = getPromptAudioUrl(); 1305 1306 function speakViaTts() { 1307 if (ttsFallbackDone) return; 1308 ttsFallbackDone = true; 1309 const utterance = buildUtterance(promptText); 1310 utterance.onstart = function () { promptSpoken = true; }; 1311 window.speechSynthesis.cancel(); 1312 window.speechSynthesis.speak(utterance); 1313 window.speechSynthesis.resume(); 1314 } 1315 1316 function speakPrompt() { 1317 ttsFallbackDone = false; 1318 if (promptAudioUrl) { 1319 const audio = new Audio(promptAudioUrl); 1320 audio.addEventListener('error', function () { 1321 // Recording missing/unreadable â use the synthesis voice. 1322 if (!promptSpoken) speakViaTts(); 1323 }); 1324 const playing = audio.play(); 1325 if (playing && playing.then) { 1326 playing.then(function () { promptSpoken = true; }) 1327 .catch(function () { /* autoplay blocked â first gesture retries */ }); 1328 } 1329 return; 1330 } 1331 speakViaTts(); 1332 } 1333 speakPrompt(); 1334 1335 function cleanup() { 1336 clearTimeout(timeoutId); 1337 document.removeEventListener('keydown', onKeydownPrompt, true); 1338 document.removeEventListener('mousedown', onFirstGesture, true); 1339 document.removeEventListener('touchstart', onFirstGesture, true); 1340 } 1341 1342 const timeoutId = setTimeout(cleanup, 15000); 1343 1344 // The first gesture grants speech permission â retry the spoken 1345 // prompt if the initial attempt was blocked. 1346 function onFirstGesture() { 1347 document.removeEventListener('mousedown', onFirstGesture, true); 1348 document.removeEventListener('touchstart', onFirstGesture, true); 1349 if (!promptSpoken && !window.speechSynthesis.speaking) speakPrompt(); 1350 } 1351 1352 function onKeydownPrompt(e) { 1353 if (e.key === 'Enter') { 1354 cleanup(); 1355 const button = document.querySelector('[data-accessibility="voiceOver"]'); 1356 if (button) { 1357 toggleVoiceOver(button); 1358 } 1359 return; 1360 } 1361 onFirstGesture(); 1362 } 1363 1364 document.addEventListener('keydown', onKeydownPrompt, true); 1365 document.addEventListener('mousedown', onFirstGesture, true); 1366 document.addEventListener('touchstart', onFirstGesture, true); 1367 } 1368 1369 1370 1371 1372 1373(function() { 1374 /** 1375 * Translations 1376 */ 1377 var pageLang = (document.documentElement.lang || 'en-US').substring(0, 2); 1378 var asbTranslations = { 1379 "en": { 1380 "voiceShortcuts": "Voice Shortcuts", 1381 "contrast": "High Contrast", 1382 "dark": "Dark Mode", 1383 "voiceOver": "Voice Over", 1384 "voiceSpeed": "Speed 1.0x", 1385 "voiceVerbosity": "Verbosity", 1386 "voicePunctuation": "Punctuation", 1387 "markerLine": "Marker Line", 1388 "readingLine": "Reading Line", 1389 "reset": "Reset", 1390 "menu": "Accessibility" 1391 }, 1392 "fr": { 1393 "voiceShortcuts": "Raccourcis vocaux", 1394 "contrast": "Contraste élevé", 1395 "dark": "Mode sombre", 1396 "voiceOver": "Synthèse vocale",
1397 "voiceSpeed": "Vitesse 1.0x", 1398 "voiceVerbosity": "Verbosité", 1399 "voicePunctuation": "Ponctuation", 1400 "markerLine": "Ligne de marquage", 1401 "readingLine": "Ligne de lecture", 1402 "reset": "Réinitialiser", 1403 "menu": "Accessibilité" 1404 }, 1405 "rw": { 1406 "voiceShortcuts": "Voice Shortcuts", 1407 "contrast": "High Contrast", 1408 "dark": "Dark Mode", 1409 "voiceOver": "Voice Over", 1410 "voiceSpeed": "Speed 1.0x", 1411 "voiceVerbosity": "Verbosity", 1412 "voicePunctuation": "Punctuation", 1413 "markerLine": "Marker Line", 1414 "readingLine": "Reading Line", 1415 "reset": "Reset", 1416 "menu": "Accessibility" 1417 } 1418 }; 1419 var langMap = asbTranslations[pageLang] || asbTranslations["en"]; 1420 function asbT(key) { 1421 return langMap[key] || key; 1422 } 1423 1424 /** 1425 * Content 1426 */ 1427 1428 // Tecla que será usada para complementar o atalho do teclado. 1429 const accessKey = 4; 1430 1431 // Definições dos botões 1432 const btns = { 1433 btnVoiceShortcuts: { 1434 active: true, 1435 dataAccessibility: "voiceShortcuts", 1436 class: "setAccessibility", 1437 icon: "FontAwesome", 1438 iconClass: ["fas", "fa-keyboard"], 1439 text: "Voice Shortcuts", 1440 }, 1441 btnHighContrast: { 1442 active: true, 1443 dataAccessibility: "contrast", 1444 class: "setAccessibility", 1445 icon: "FontAwesome", 1446 iconClass: ["fas", "fa-adjust"], 1447 text: "High Contrast", 1448 }, 1449 btnDarkMode: { 1450 active: true, 1451 dataAccessibility: "dark", 1452 class: "setAccessibility", 1453 icon: "FontAwesome", 1454 iconClass: ["fas", "fa-moon"], 1455 text: "Dark Mode", 1456 }, 1457// btnReadScreanW: { 1458// active: true, 1459// dataAccessibility: "C:\Users\Hp-PC\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Accessibility\%windir%\system32\narrator.exe", 1460// class: "setAccessibility", 1461// icon: "FontAwesome", 1462// iconClass: ["fab", "fa-windows"], 1463// text: "Windows Voice Over", 1464 1465// }, 1466 // btnReadScreenM: { 1467 // active: true, 1468 // id: "voiceOverButton", 1469 // dataAccessibility: "javascript:runProgram()", 1470 // class: "setAccessibility", 1471 // icon: "FontAwesome", 1472 // iconClass: ["fab", "fa-apple"], 1473 // text: "Voice Over", 1474 1475 1476 1477 1478 // }, 1479 btnReadScreenM: { 1480 active: true, 1481 dataAccessibility: "voiceOver", 1482 class: "setAccessibility", 1483 icon: "FontAwesome", 1484 iconClass: ["fas", "fa-volume-up"], 1485 text: "Voice Over", 1486 }, 1487 btnVoiceSpeed: { 1488 active: true, 1489 dataAccessibility: "voiceSpeed", 1490 class: "setAccessibility", 1491 icon: "FontAwesome", 1492 iconClass: ["fas", "fa-tachometer-alt"], 1493 text: "Speed 1.0x", 1494 }, 1495 btnVoiceVerbosity: { 1496 active: true, 1497 dataAccessibility: "voiceVerbosity", 1498 class: "setAccessibility", 1499 icon: "FontAwesome", 1500 iconClass: ["fas", "fa-comment-dots"], 1501 text: "Verbosity", 1502 }, 1503 btnVoicePunctuation: { 1504 active: true, 1505 dataAccessibility: "voicePunctuation", 1506 class: "setAccessibility", 1507 icon: "FontAwesome", 1508 iconClass: ["fas", "fa-ellipsis-h"], 1509 text: "Punctuation", 1510 }, 1511 1512 1513 1514 1515 1516 // btnDecFont: { 1517 // active: true, 1518 // dataAccessibility: "decFont", 1519 // class: "setAccessibility", 1520 // icon: "A-", 1521 // iconClass: "", 1522 // text: "Decrease", 1523 // }, 1524 btnMarkerLine: { 1525 active: true, 1526 dataAccessibility: "markerLine", 1527 class: "setAccessibility", 1528 icon: "FontAwesome", 1529 iconClass: ["fas", "fa-ruler-horizontal"], 1530 text: "Marker Line", 1531 }, 1532 btnReadingLine: { 1533 active: true, 1534 dataAccessibility: "readingLine", 1535 class: "setAccessibility", 1536 icon: "FontAwesome", 1537 iconClass: ["fas", "fa-ruler-horizontal"], 1538 text: "Reading Line", 1539 }, 1540 btnReset: { 1541 active: true, 1542 dataAccessibility: "reset", 1543 class: "setAccessibility", 1544 icon: "FontAwesome", 1545 iconClass: ["fas", "fa-redo-alt"], 1546 text: "Reset", 1547 }, 1548 } 1549 1550 /** 1551 * Creating the bar 1552 */ 1553 1554 const accessibilityBar = document.createElement("div"); 1555 accessibilityBar.id = "accessibilityBar"; 1556 document.body.insertBefore(accessibilityBar, document.body.firstChild); 1557 1558 // Container for action buttons (scrollable) 1559 const accessibilityList = document.createElement("div"); 1560 accessibilityList.id = "accessibilityList"; 1561 accessibilityBar.appendChild(accessibilityList); 1562 1563 /** 1564 * Creating main button 1565 */ 1566 let btnAccessibilityBar; 1567 1568 function createMainButton() { 1569 btnAccessibilityBar = document.createElement("button"); 1570 btnAccessibilityBar.id = "universalAccessBtn"; 1571 btnAccessibilityBar.type = "button"; 1572 btnAccessibilityBar.accessKey = accessKey; 1573 accessibilityBar.appendChild(btnAccessibilityBar); 1574 1575 const icon = document.createElement("i"); 1576 btnAccessibilityBar.appendChild(icon); 1577 icon.classList.add("fas", "fa-universal-access"); 1578 1579 const spanText = document.createElement("span"); 1580 const spanTextNode = document.createTextNode(asbT("menu")); 1581 spanText.appendChild(spanTextNode); 1582 btnAccessibilityBar.appendChild(spanText); 1583 } 1584 createMainButton(); 1585 1586 /** 1587 * Creating anothers button 1588 */ 1589 1590 function createButtons(el) { 1591 const button = document.createElement("button"); 1592 button.type = "button"; 1593 button.classList.add(el.class); 1594 button.setAttribute('data-accessibility', el.dataAccessibility); 1595 if (el.dataAccessibility === "voiceSpeed" || 1596 el.dataAccessibility === "voiceVerbosity" || 1597 el.dataAccessibility === "voicePunctuation") { 1598 button.setAttribute("aria-live", "polite"); 1599 } 1600 accessibilityList.appendChild(button); 1601 1602 const wrapIcon = document.createElement("strong"); 1603 button.appendChild(wrapIcon); 1604 1605 if (el.icon === "FontAwesome") { 1606 const icon = document.createElement("i"); 1607 wrapIcon.appendChild(icon); 1608 icon.classList.add(...el.iconClass); 1609 } else { 1610 const textIcon = document.createTextNode(el.icon); 1611 wrapIcon.appendChild(textIcon); 1612 } 1613 1614 const label = document.createElement("span"); 1615 label.className = "accessibility-label"; 1616 label.textContent = asbT(el.dataAccessibility) || el.text; 1617 if (el.dataAccessibility === "voiceSpeed") { 1618 label.setAttribute("data-voice-rate-indicator", "true"); 1619 } 1620 if (el.dataAccessibility === "voiceVerbosity") { 1621 label.setAttribute("data-voice-verbosity-indicator", "true"); 1622 } 1623 if (el.dataAccessibility === "voicePunctuation") { 1624 label.setAttribute("data-voice-punctuation-indicator", "true"); 1625 } 1626 button.appendChild(label); 1627 }
1628 Object.keys(btns).forEach(function (item) { 1629 if(btns[item].active){ 1630 createButtons(btns[item]); 1631 } 1632 }); 1633 1634 1635 const html = document.documentElement; //<html> for font-size settings 1636 const body = document.body; //<body> for the adjusts classes 1637 const btnAccessibility = document.querySelectorAll("[data-accessibility]"); // Getting settings buttons 1638 1639 if (btnAccessibilityBar) { 1640 setTimeout(function() { 1641 btnAccessibilityBar.classList.add("collapsed"); 1642 }, 2000); 1643 } 1644 1645 /** 1646 * ReadingLine 1647 */ 1648 1649 const readingLine = document.createElement("div"); 1650 readingLine.id = "readingLine"; 1651 document.body.insertBefore(readingLine, document.body.firstChild); 1652 1653 html.addEventListener("mousemove", function(e) { 1654 if (body.classList.contains("accessibility_readingLine")) { 1655 let linePositionY = e.pageY - 20; 1656 // console.log(linePositionY); 1657 const elReadingLine = document.querySelector("#readingLine"); // Toggle button 1658 elReadingLine.style.top = `${linePositionY}px`; 1659 } 1660 }); 1661 1662 /** 1663 * MarkerLine 1664 */ 1665 1666 const markerLine = document.createElement("div"); 1667 markerLine.id = "markerLine"; 1668 document.body.insertBefore(markerLine, document.body.firstChild); 1669 1670 html.addEventListener("mousemove", function(e) { 1671 if (body.classList.contains("accessibility_markerLine")) { 1672 let linePositionY = e.pageY - 20; 1673 // console.log(linePositionY); 1674 const elmarkerLine = document.querySelector("#markerLine"); // Toggle button 1675 elmarkerLine.style.top = `${linePositionY}px`; 1676 } 1677 }); 1678 1679 /* 1680=== === === === === === === === === === === === === === === === === === 1681=== === === === === === === === openBar === === === === === === === === 1682=== === === === === === === === === === === === === === === === === === 1683*/ 1684 1685btnAccessibilityBar.addEventListener("click", () => { 1686 accessibilityBar.classList.toggle("active"); 1687 1688 // Change icon between person and X 1689 const icon = btnAccessibilityBar.querySelector("i"); 1690 if (accessibilityBar.classList.contains("active")) { 1691 icon.classList.remove("fa-universal-access"); 1692icon.classList.add("fa-times", "fa-light"); 1693 } else { 1694 icon.classList.remove("fa-times"); 1695 icon.classList.add("fa-universal-access"); 1696 } 1697}); 1698 /* 1699=== === === === === === === === === === === === === === === === === === 1700=== === === === === === toggleAccessibilities === === === === === === 1701=== === === === === === === === === === === === === === === === === === 1702*/ 1703 1704 function toggleAccessibilities(action) { 1705 switch (action) { 1706 case "contrast": 1707 window.toggleContrast(); 1708 break; 1709 case "dark": 1710 window.toggleDark(); 1711 break; 1712 case "incFont": 1713 window.toggleFontSize(action); 1714 break; 1715 case "oriFont": 1716 window.toggleFontSize(action); 1717 break; 1718 case "decFont": 1719 window.toggleFontSize(action); 1720 break; 1721 case "readingLine": 1722 body.classList.toggle("accessibility_readingLine"); 1723 break; 1724 case "markerLine": 1725 body.classList.toggle("accessibility_markerLine"); 1726 break; 1727 case "reset": 1728 Dark.currentState === true ? Dark.setState(false) : null; 1729 Contrast.currentState === true ? Contrast.setState(false) : null; 1730 window.toggleFontSize("oriFont"); 1731 body.classList.remove("accessibility_readingLine"); 1732 body.classList.remove("accessibility_markerLine"); 1733 break; 1734 case "voiceSpeed": 1735 if (voiceOverEnabled) { 1736 const preview = buildUtterance(`${speechText('Speed')} ${voiceRate.toFixed(1)}x`); 1737 window.speechSynthesis.cancel(); 1738 window.speechSynthesis.speak(preview); 1739 window.speechSynthesis.resume(); 1740 } 1741 break; 1742 case "voiceVerbosity": 1743 if (voiceVerbosity === "verbose") voiceVerbosity = "compact"; 1744 else if (voiceVerbosity === "compact") voiceVerbosity = "minimal"; 1745 else voiceVerbosity = "verbose"; 1746 sessionStorage.setItem(voiceVerbosityStorageKey, voiceVerbosity); 1747 updateVoiceVerbosityUI();
1748 if (isSpeaking) { 1749 restartSpeechIfActive(); 1750 } 1751 break; 1752 case "voicePunctuation": 1753 voicePunctuation = (voicePunctuation === "all") ? "none" : "all"; 1754 sessionStorage.setItem(voicePunctuationStorageKey, voicePunctuation); 1755 updateVoicePunctuationUI(); 1756 if (isSpeaking) { 1757 restartSpeechIfActive(); 1758 } else if (voiceOverEnabled) { 1759 const sample = speechText("Test, period. Question? Exclamation! Colon: semicolon; dash - slash /."); 1760 window.speechSynthesis.cancel(); 1761 window.speechSynthesis.speak(buildUtterance(sample)); 1762 window.speechSynthesis.resume(); 1763 } 1764 break; 1765 case "voiceShortcuts": 1766 if (!voiceOverEnabled) { 1767 toggleVoiceOver(button); 1768 } 1769 const shortcutsText = [ 1770 "Shortcuts.", 1771 "Quick Nav toggle: press Left Arrow and Right Arrow together.", 1772 "When Quick Nav is on: H for next heading, B for next button, L for next link, F for next form field.", 1773 "Arrow Up increases voice speed. Arrow Down decreases voice speed.", 1774 "Tab moves focus to next control." 1775 ].map(speechText).join(" "); 1776 window.speechSynthesis.cancel(); 1777 window.speechSynthesis.speak(buildUtterance(shortcutsText)); 1778 window.speechSynthesis.resume(); 1779 break; 1780 default: 1781 break; 1782 } 1783 accessibilityBar.classList.toggle("active"); 1784 } 1785 1786 updateVoiceRateUI(); 1787 updateVoiceVerbosityUI(); 1788 updateVoicePunctuationUI(); 1789 promptVoiceOverOnHome(); 1790 1791 if (voiceAutoResume) { 1792 const button = document.querySelector('[data-accessibility="voiceOver"]'); 1793 if (button && !voiceOverEnabled) { 1794 // On the PDF reader page, wait until PDF.js has extracted the text into 1795 // the DOM â otherwise innerText only contains the title and breadcrumb. 1796 const pdfReader = document.body.getAttribute('data-pdf-reader'); 1797 if (pdfReader === 'loading') { 1798 window.addEventListener('asb:pdf-text-ready', function () { 1799 if (!voiceOverEnabled) toggleVoiceOver(button); 1800 }, { once: true }); 1801 } else { 1802 toggleVoiceOver(button); 1803 } 1804 } 1805 } 1806 1807btnAccessibility.forEach(button => 1808 button.addEventListener("click", () => { 1809 const action = button.dataset.accessibility; 1810 1811 if (action === "voiceOver") { 1812 toggleVoiceOver(button); 1813 } else { 1814 toggleAccessibilities(action); 1815 } 1816 }) 1817 ); 1818 1819 // Keep aria-expanded in sync for mobile menu 1820 const menuTrigger = document.getElementById('primary-menu-trigger'); 1821 if (menuTrigger) { 1822 const updateMenuAria = () => { 1823 // functions.js toggles body.primary-menu-open when the responsive menu is opened. 1824 // Rely on that single source of truth to avoid "always expanded" false positives. 1825 const isOpen = document.body.classList.contains('primary-menu-open'); 1826 menuTrigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); 1827 menuTrigger.setAttribute('aria-label', isOpen ? 'Collapse main menu' : 'Open main menu'); 1828 }; 1829 1830 updateMenuAria(); 1831 menuTrigger.addEventListener('click', () => { 1832 setTimeout(updateMenuAria, 0); 1833 }); 1834 menuTrigger.addEventListener('keydown', (e) => { 1835 if (e.key === 'Enter' || e.key === ' ') { 1836 e.preventDefault(); 1837 menuTrigger.click(); 1838 } 1839 }); 1840 } 1841 1842 /* 1843=== === === === === === === === === === === === === === === === === === 1844=== === === === === === === FontSize === === === === === === === === 1845=== === === === === === === === === === === === === === === === === === 1846*/ 1847 1848 const htmlFontSize = parseFloat( 1849 getComputedStyle(document.documentElement).getPropertyValue("font-size") 1850 ); 1851 let FontSize = { 1852 storage: "fontSizeState", 1853 cssClass: "fontSize", 1854 currentState: null, 1855 check: checkFontSize, 1856 getState: getFontSizeState, 1857 setState: setFontSizeState, 1858 toggle: toggleFontSize, 1859 updateView: updateViewFontSize 1860 }; 1861 1862 window.toggleFontSize = function(action) { 1863 FontSize.toggle(action); 1864 }; 1865 1866 FontSize.check(); 1867 1868 function checkFontSize() { 1869 this.updateView(); 1870 } 1871 1872 function getFontSizeState() { 1873 return sessionStorage.getItem(this.storage) 1874 ? sessionStorage.getItem(this.storage) 1875 : 100; 1876 } 1877 1878 function setFontSizeState(state) { 1879 sessionStorage.setItem(this.storage, "" + state); 1880 this.currentState = state; 1881 this.updateView(); 1882 } 1883 1884 function updateViewFontSize() { 1885 if (this.currentState === null) this.currentState = this.get
1885State(); 1886 1887 this.currentState 1888 ? (html.style.fontSize = (this.currentState / 100) * htmlFontSize + "px") 1889 : ""; 1890 1891 this.currentState 1892 ? body.classList.add(this.cssClass + this.currentState) 1893 : ""; 1894 } 1895 1896 function toggleFontSize(action) { 1897 switch (action) { 1898 case "incFont": 1899 if (parseFloat(this.currentState) < 400) { 1900 body.classList.remove(this.cssClass + this.currentState); 1901 this.setState(parseFloat(this.currentState) + 20); 1902 } else { 1903 alert("Limite 400px"); 1904 } 1905 break; 1906 case "oriFont": 1907 body.classList.remove(this.cssClass + this.currentState); 1908 this.setState(100); 1909 break; 1910 case "decFont": 1911 if (parseFloat(this.currentState) > 40) { 1912 body.classList.remove(this.cssClass + this.currentState); 1913 this.setState(parseFloat(this.currentState) - 20); 1914 } else { 1915 alert("Limite 400px"); 1916 } 1917 break; 1918 default: 1919 break; 1920 } 1921 } 1922 1923 /* 1924=== === === === === === === === === === === === === === === === === === 1925=== === === === === === HighConstrast === === === === === === === === 1926=== === === === === === === === === === === === === === === === === === 1927*/ 1928 let Contrast = { 1929 storage: "contrastState", 1930 cssClass: "contrast", 1931 currentState: null, 1932 check: checkContrast, 1933 getState: getContrastState, 1934 setState: setContrastState, 1935 toggle: toggleContrast, 1936 updateView: updateViewContrast 1937 }; 1938 1939 window.toggleContrast = function() { 1940 Contrast.toggle(); 1941 }; 1942 1943 Contrast.check(); 1944 1945 function checkContrast() { 1946 this.updateView(); 1947 } 1948 1949 function getContrastState() { 1950 return sessionStorage.getItem(this.storage) === "true"; 1951 } 1952 1953 function setContrastState(state) { 1954 sessionStorage.setItem(this.storage, "" + state); 1955 this.currentState = state; 1956 this.updateView(); 1957 } 1958 1959 function updateViewContrast() { 1960 if (this.currentState === null) this.currentState = this.getState(); 1961 1962 this.currentState 1963 ? body.classList.add(this.cssClass) 1964 : body.classList.remove(this.cssClass); 1965 } 1966 1967 function toggleContrast() { 1968 this.setState(!this.currentState); 1969 Dark.currentState === true ? Dark.setState(false) : null; 1970 } 1971 1972 /* 1973=== === === === === === === === === === === === === === === === === === 1974=== === === === === === === DarkMode === === === === === === === === 1975=== === === === === === === === === === === === === === === === === === 1976*/ 1977 let Dark = { 1978 storage: "darkState", 1979 cssClass: "darkmode", 1980 currentState: null, 1981 check: checkDark,
1982 getState: getDarkState, 1983 setState: setDarkState, 1984 toggle: toggleDark, 1985 updateView: updateViewDark 1986 }; 1987 1988 window.toggleDark = function() { 1989 Dark.toggle(); 1990 }; 1991 1992 Dark.check(); 1993 1994 function checkDark() { 1995 this.updateView(); 1996 } 1997 1998 function getDarkState() { 1999 return sessionStorage.getItem(this.storage) === "true"; 2000 } 2001 2002 function setDarkState(state) { 2003 sessionStorage.setItem(this.storage, "" + state); 2004 this.currentState = state; 2005 this.updateView(); 2006 } 2007 2008 function updateViewDark() { 2009 if (this.currentState === null) this.currentState = this.getState(); 2010 2011 this.currentState 2012 ? body.classList.add(this.cssClass) 2013 : body.classList.remove(this.cssClass); 2014 } 2015 2016 function toggleDark() { 2017 this.setState(!this.currentState); 2018 Contrast.currentState === true ? Contrast.setState(false) : null; 2019 } 2020 2021})(); 2022 2023// Keep accessibility bar from overlapping the footer when scrolling 2024(function () { 2025 var bar = null; 2026 var footer = null; 2027 2028 function clampBar() { 2029 if (!bar) bar = document.getElementById('accessibilityBar'); 2030 if (!footer) footer = document.getElementById('footerr'); 2031 if (!bar || !footer) return; 2032 2033 var barH = bar.offsetHeight; 2034 var fTop = footer.getBoundingClientRect().top; 2035 var vh = window.innerHeight; 2036 var vw = window.innerWidth; 2037 2038 // Default centred position â matches the CSS breakpoints 2039 var defaultTopPx = vh * (vw <= 576 ? 0.40 : vw <= 992 ? 0.45 : 0.50); 2040 2041 // With top:T and translateY(-50%): 2042 // visual bottom = T + barH/2 2043 // We need: T + barH/2 <= fTop - 10 â T <= fTop - barH/2 - 10 2044 var maxTopPx = fTop - barH * 0.5 - 10; 2045 2046 // Use whichever is higher on screen (smaller value) 2047 var newTop = Math.min(defaultTopPx, maxTopPx); 2048 2049 // Absolute floor: keep at least 30 px of the bar visible at the top of the viewport 2050 // (allows bar to slide almost fully off-screen rather than drop into the footer) 2051 newTop = Math.max(newTop, 30 - barH * 0.5); 2052 2053 bar.style.top = newTop + 'px'; 2054 } 2055 2056 window.addEventListener('scroll', clampBar, { passive: true }); 2057 window.addEventListener('resize', clampBar); 2058 if (document.readyState === 'loading') { 2059 document.addEventListener('DOMContentLoaded', clampBar); 2060 } else { 2061 clampBar(); 2062 } 2063})(); 2064 2065// PDF Accessible Reader â route PDF clicks through pdf-reader.php only when voice-over is ON 2066(function () { 2067 // Never intercept on the reader page itself (so "Open Original PDF" always works) 2068 if (window.location.pathname.indexOf('pdf-reader.php') !== -1) return; 2069 2070 function isPdfLink(el) { 2071 if (!el || el.tagName !== 'A') return false; 2072 var href = el.getAttribute('href'); 2073 if (!href || href.indexOf('#') === 0 || href.indexOf('javascript:') === 0) return false; 2074 if (href.indexOf('pdf-reader.php') !== -1) return false; 2075 try { 2076 return new URL(href, window.location.href).pathname.toLowerCase().endsWith('.pdf'); 2077 } catch (e) { 2078 return href.toLowerCase().indexOf('.pdf') !== -1; 2079 } 2080 } 2081 2082 document.addEventListener('click', function (e) { 2083 var a = e.target.closest ? e.target.closest('a') : null; 2084 if (!isPdfLink(a)) return; 2085 2086 // Let the link behave normally when voice-over is off 2087 if (sessionStorage.getItem('voiceOverEnabled') !== 'true') return; 2088 2089 e.preventDefault(); 2090 var href = a.getAttribute('href'); 2091 var returnPath = window.location.pathname + window.location.search; 2092 window.location.href = '/pdf-reader.php?file=' + encodeURIComponent(href) + 2093 '&return=' + encodeURIComponent(returnPath); 2094 }, true); 2095})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.