1/** 2 * Shared Web Speech voice-typing helper for search compose fields. 3 * Input aid only â never submits the form. 4 */ 5(function (global) { 6 "use strict"; 7 8 const root = (global.PaterikaUtils = global.PaterikaUtils || {}); 9 10 const DEFAULT_LANGUAGE = "el-GR"; 11 const LABEL_IDLE = "ΦÏνηÏική ÏληκÏÏολÏγηÏη"; 12 const LABEL_LISTENING = "ÎιακοÏή ÏÏνηÏÎ¹ÎºÎ®Ï ÏληκÏÏολÏγηÏηÏ"; 13 const STATUS_LISTENING = "ÎκÏÏαÏηâ¦"; 14 const STATUS_STOPPED = "Î ÏÏνηÏική ÏληκÏÏολÏγηÏη ÏÏαμάÏηÏε."; 15 const STATUS_PERMISSION = "Îεν δÏθηκε ÏÏÏÏβαÏη ÏÏο μικÏÏÏÏνο."; 16 const STATUS_NO_SPEECH = "Îεν ανιÏνεÏθηκε ομιλία."; 17 const STATUS_ERROR = "Î ÏÏνηÏική ÏληκÏÏολÏγηÏη δεν ολοκληÏÏθηκε."; 18 19 const MIC_ICON_SVG = 20 '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + 21 '<path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"></path>' + 22 '<path d="M19 10v1a7 7 0 0 1-14 0v-1"></path>' + 23 '<path d="M12 18v3"></path>' + 24 '<path d="M8 21h8"></path>' + 25 "</svg>"; 26 27 /** @type {ReturnType<typeof createController> | null} */ 28 let activeController = null; 29 /** @type {HTMLElement | null} */ 30 let sharedStatusEl = null; 31 let statusClearTimer = null; 32 33 function getSpeechRecognitionCtor() { 34 return global.SpeechRecognition || global.webkitSpeechRecognition || null; 35 } 36 37 function isSpeechRecognitionSupported() { 38 return typeof getSpeechRecognitionCtor() === "function"; 39 } 40 41 function getDocument() { 42 return global.document || null; 43 } 44 45 function isTextField(el) { 46 if (!el) { 47 return false; 48 } 49 const Input = global.HTMLInputElement; 50 const TextArea = global.HTMLTextAreaElement; 51 if (Input && el instanceof Input) { 52 return true; 53 } 54 if (TextArea && el instanceof TextArea) { 55 return true; 56 } 57 const tag = String(el.tagName || "").toUpperCase(); 58 return tag === "INPUT" || tag === "TEXTAREA"; 59 } 60 61 function isElement(el) { 62 if (!el) { 63 return false; 64 } 65 const ElementCtor = global.HTMLElement || global.Element; 66 if (ElementCtor && el instanceof ElementCtor) { 67 return true; 68 } 69 return typeof el.appendChild === "function" && typeof el.setAttribute === "function"; 70 } 71 72 function getOrCreateSharedStatusRegion() { 73 const doc = getDocument(); 74 if (!doc?.body) { 75 return null; 76 } 77 if (sharedStatusEl && doc.body.contains(sharedStatusEl)) { 78 return sharedStatusEl; 79 } 80 let el = doc.getElementById("voiceTypingStatus"); 81 if (!isElement(el)) { 82 el = doc.createElement("div"); 83 el.id = "voiceTypingStatus"; 84 el.className = "visually-hidden voice-typing-status"; 85 el.setAttribute("role", "status"); 86 el.setAttribute("aria-live", "polite"); 87 el.setAttribute("aria-atomic", "true"); 88 doc.body.appendChild(el); 89 } 90 sharedStatusEl = el; 91 return sharedStatusEl; 92 } 93 94 function announceStatus(message) { 95 const el = getOrCreateSharedStatusRegion(); 96 if (!el) { 97 return; 98 } 99 if (statusClearTimer) { 100 global.clearTimeout(statusClearTimer); 101 statusClearTimer = null; 102 } 103 // Retrigger polite announcements when the same message repeats. 104 el.textContent = ""; 105 const text = String(message || "").trim(); 106 if (text) { 107 el.textContent = text; 108 statusClearTimer = global.setTimeout(() => { 109 if (el.textContent === text) { 110 el.textContent = ""; 111 } 112 statusClearTimer = null; 113 }, 4000); 114 } 115 } 116 117 function isWordChar(ch) { 118 return /[\w\u0370-\u03FF\u1F00-\u1FFF]/.test(ch); 119 } 120 121 /** 122 * Insert transcript at the current selection without replacing the whole value. 123 * @param {HTMLInputElement | HTMLTextAreaElement} input 124 * @param {string} rawText 125 */ 126 function insertTranscriptAtCursor(input, rawText) { 127 if (!isTextField(input)) { 128 return; 129 } 130 let insert = String(rawText || "").replace(/\s+/g, " ").trim(); 131 if (!insert) { 132 return; 133 } 134
135 const value = String(input.value || ""); 136 const start = 137 typeof input.selectionStart === "number" ? input.selectionStart : value.length; 138 const end = typeof input.selectionEnd === "number" ? input.selectionEnd : value.length; 139 const before = value.slice(0, start); 140 const after = value.slice(end); 141 142 if (before && isWordChar(before[before.length - 1]) && isWordChar(insert[0])) { 143 insert = " " + insert; 144 } 145 if (/\s$/.test(before) && /^\s/.test(insert)) { 146 insert = insert.replace(/^\s+/, ""); 147 } 148 if (/\s$/.test(insert) && /^\s/.test(after)) { 149 insert = insert.replace(/\s+$/, ""); 150 } 151 if (insert && after && isWordChar(insert[insert.length - 1]) && isWordChar(after[0])) { 152 insert = insert + " "; 153 } 154 155 const next = before + insert + after; 156 const caret = before.length + insert.length; 157 input.value = next; 158 try { 159 input.setSelectionRange(caret, caret); 160 } catch (_) { 161 // Some input types reject selection changes. 162 } 163 164 const doc = getDocument(); 165 const EventCtor = global.Event || (doc ? doc.defaultView?.Event : null); 166 if (typeof EventCtor === "function") { 167 input.dispatchEvent(new EventCtor("input", { bubbles: true })); 168 } 169 try { 170 input.focus(); 171 } catch (_) { 172 // Ignore focus failures in non-interactive environments. 173 } 174 } 175 176 function createMicButton(doc) { 177 const button = doc.createElement("button"); 178 button.type = "button"; 179 button.className = "search-compose__mic"; 180 button.setAttribute("aria-label", LABEL_IDLE); 181 button.setAttribute("aria-pressed", "false"); 182 183 const icon = doc.createElement("span"); 184 icon.className = "search-compose__mic-icon"; 185 icon.setAttribute("aria-hidden", "true"); 186 icon.innerHTML = MIC_ICON_SVG; 187 188 const listeningDot = doc.createElement("span"); 189 listeningDot.className = "search-compose__mic-listening"; 190 listeningDot.setAttribute("aria-hidden", "true"); 191 192 button.appendChild(icon); 193 button.appendChild(listeningDot); 194 return button; 195 } 196 197 function createController(options) { 198 const doc = getDocument(); 199 const SpeechRecognition = getSpeechRecognitionCtor(); 200 const input = options.input; 201 const buttonRoot = options.buttonRoot; 202 const language = String(options.language || DEFAULT_LANGUAGE).trim() || DEFAULT_LANGUAGE; 203 const onStatusChange = 204 typeof options.onStatusChange === "function" ? options.onStatusChange : null; 205 206 if (!doc || !SpeechRecognition || !input || !buttonRoot) { 207 return null; 208 } 209 210 const button = createMicButton(doc); 211 buttonRoot.appendChild(button); 212 buttonRoot.classList.add("search-compose__mic-root--mounted"); 213 214 const fieldWrap = input.closest(".search-compose__field-wrap"); 215 if (fieldWrap) { 216 fieldWrap.classList.add("search-compose__field-wrap--has-mic"); 217 } 218 const readerControl = input.closest(".reader-catalog-quicksearch__control"); 219 if (readerControl) { 220 readerControl.classList.add("reader-catalog-quicksearch__control--has-mic"); 221 } 222 223 let recognition = null; 224 let listening = false; 225 let stoppingIntentionally = false; 226 let lastHandledResultIndex = -1; 227 let destroyed = false; 228 229 function setListeningUi(isListening) { 230 listening = Boolean(isListening); 231 button.classList.toggle("is-listening", listening); 232 button.setAttribute("aria-pressed", listening ? "true" : "false"); 233 button.setAttribute("aria-label", listening ? LABEL_LISTENING : LABEL_IDLE); 234 if (onStatusChange) { 235 onStatusChange(listening ? "listening" : "idle"); 236 } 237 } 238 239 function applyResultEvent(event) { 240 const results = event?.results; 241 if (!results || typeof results.length !== "number") { 242 return; 243 } 244 245 // Safe path: commit only final transcripts. Interim results are ignored for the field 246 // so temporary speech does not permanently mutate the query until recognition finalizes. 247 let finalChunk = ""; 248 for (let i = 0; i < results.length; i += 1) { 249 const result = results[i]; 250 if (!result || !result.isFinal || !result[0]) { 251 continue; 252 } 253 if (i <= lastHandledResultIndex) { 254 continue; 255 } 256 const transcript = String(result[0].transcript || ""); 257 if (!transcript) { 258 lastHandledResultIndex = i; 259 continue; 260 } 261 finalChunk += transcript; 262 lastHandledResultIndex = i; 263 } 264 265 if (finalChunk) { 266 insertTranscriptAtCursor(input, finalChunk); 267 } 268 } 269 270 function stopRecognition(optionsStop = {}) { 271 const announce = optionsStop.announce !== false; 272 stoppingIntentionally = true; 273 if (recognition) { 274 try { 275 recognition.stop(); 276 } catch (_) { 277 /* already stopped */ 278 } 279 } 280 if (listening) { 281 setListeningUi(false); 282 if (announce) { 283 announceStatus(STATUS_STOPPED); 284 } 285 } 286 if (activeController === controller) { 287 activeController = null; 288 } 289 } 290 291 function handleError(event) { 292 const errorCode = String(event?.error || "").toLowerCase(); 293 294 let message = STATUS_ERROR; 295 if (errorCode === "not-allowed" || errorCode === "service-not-allowed") { 296 message = STATUS_PERMISSION; 297 } else if (errorCode === "no-speech") { 298 message = STATUS_NO_SPEECH; 299 } else if (errorCode === "aborted" && stoppingIntentionally) { 300 message = STATUS_STOPPED; 301 } 302 303 setListeningUi(false); 304 if (activeController === controller) { 305 activeController = null; 306 } 307 announceStatus(message); 308 if (onStatusChange) { 309 onStatusChange("error", errorCode); 310 } 311 stoppingIntentionally = false;
312 } 313 314 function handleEnd() { 315 const wasListening = listening; 316 setListeningUi(false); 317 if (activeController === controller) { 318 activeController = null; 319 } 320 if (wasListening && !stoppingIntentionally) { 321 announceStatus(STATUS_STOPPED); 322 } 323 stoppingIntentionally = false; 324 recognition = null; 325 } 326 327 function startRecognition() { 328 if (destroyed || listening) { 329 return; 330 } 331 if (activeController && activeController !== controller) { 332 activeController.stop({ announce: false }); 333 } 334 335 const Ctor = getSpeechRecognitionCtor(); 336 if (!Ctor) { 337 return; 338 } 339 340 recognition = new Ctor(); 341 recognition.lang = language; 342 recognition.interimResults = true; 343 recognition.continuous = false; 344 recognition.maxAlternatives = 1; 345 346 recognition.onresult = applyResultEvent; 347 recognition.onerror = handleError; 348 recognition.onend = handleEnd; 349 350 lastHandledResultIndex = -1; 351 stoppingIntentionally = false; 352 activeController = controller; 353 setListeningUi(true); 354 announceStatus(STATUS_LISTENING); 355 356 try { 357 recognition.start(); 358 } catch (_) { 359 setListeningUi(false); 360 activeController = null; 361 announceStatus(STATUS_ERROR); 362 recognition = null; 363 } 364 } 365 366 function onButtonClick(event) { 367 event.preventDefault(); 368 event.stopPropagation(); 369 if (listening) { 370 stopRecognition({ announce: true }); 371 return; 372 } 373 startRecognition(); 374 } 375 376 button.addEventListener("click", onButtonClick); 377 378 const controller = { 379 button, 380 start() { 381 startRecognition(); 382 }, 383 stop(stopOptions) { 384 stopRecognition(stopOptions || {}); 385 }, 386 isListening() { 387 return listening; 388 }, 389 cleanup() { 390 if (destroyed) { 391 return; 392 } 393 destroyed = true; 394 button.removeEventListener("click", onButtonClick); 395 stopRecognition({ announce: false }); 396 if (recognition) { 397 try { 398 recognition.onresult = null; 399 recognition.onerror = null; 400 recognition.onend = null; 401 recognition.abort(); 402 } catch (_) { 403 /* ignore */ 404 } 405 recognition = null; 406 } 407 button.remove(); 408 buttonRoot.classList.remove("search-compose__mic-root--mounted"); 409 if (fieldWrap) { 410 fieldWrap.classList.remove("search-compose__field-wrap--has-mic"); 411 } 412 if (readerControl) { 413 readerControl.classList.remove("reader-catalog-quicksearch__control--has-mic"); 414 } 415 if (activeController === controller) { 416 activeController = null; 417 } 418 }, 419 }; 420 421 return controller; 422 } 423 424 /** 425 * Mount a voice-typing control next to an input/textarea. 426 * Renders nothing when Web Speech API is unavailable. 427 * 428 * @param {{ 429 * input: HTMLInputElement | HTMLTextAreaElement, 430 * buttonRoot: HTMLElement, 431 * language?: string, 432 * onStatusChange?: (status: string, detail?: string) => void 433 * }} options 434 * @returns {{ start: Function, stop: Function, cleanup: Function, isListening: Function, button: HTMLButtonElement } | null} 435 */ 436 function mountSpeechInput(options = {}) { 437 if (!isSpeechRecognitionSupported()) { 438 return null; 439 } 440 const input = options.input; 441 const buttonRoot = options.buttonRoot; 442 if (!isTextField(input) || !isElement(buttonRoot)) { 443 return null; 444 } 445 if (buttonRoot.querySelector(".search-compose__mic")) { 446 return null; 447 } 448 return createController(options); 449 } 450 451 function resolveVoiceTypingField(doc, buttonRoot) { 452 if (isElement(buttonRoot)) { 453 const parent = buttonRoot.parentElement || buttonRoot.parentNode; 454 if (parent && typeof parent.querySelector === "function") { 455 const local = 456 parent.querySelector("#readerCatalogQuickSearchInput") || 457 parent.querySelector("#queryInput") || 458 parent.querySelector("textarea, input[type='search'], input[type='text']"); 459 if (isTextField(local)) { 460 return local; 461 } 462 } 463 const readerHost = 464 typeof buttonRoot.closest === "function" 465 ? buttonRoot.closest("[data-reader-catalog-quicksearch]") 466 : null; 467 if (readerHost) { 468 const readerInput = readerHost.querySelector("#readerCatalogQuickSearchInput");
469 if (isTextField(readerInput)) { 470 return readerInput; 471 } 472 } 473 } 474 const queryInput = doc.getElementById("queryInput"); 475 if (isTextField(queryInput)) { 476 return queryInput; 477 } 478 const readerInput = doc.getElementById("readerCatalogQuickSearchInput"); 479 if (isTextField(readerInput)) { 480 return readerInput; 481 } 482 return null; 483 } 484 485 function autoMountSearchCompose() { 486 const doc = getDocument(); 487 if (!doc || !isSpeechRecognitionSupported()) { 488 return null; 489 } 490 const buttonRoot = doc.querySelector("[data-voice-typing-root]"); 491 if (!isElement(buttonRoot)) { 492 return null; 493 } 494 const input = resolveVoiceTypingField(doc, buttonRoot); 495 if (!isTextField(input)) { 496 return null; 497 } 498 return mountSpeechInput({ 499 input, 500 buttonRoot, 501 language: DEFAULT_LANGUAGE, 502 }); 503 } 504 505 let pageController = null; 506 507 function initVoiceTypingOnPage() { 508 if (pageController) { 509 return pageController; 510 } 511 pageController = autoMountSearchCompose(); 512 return pageController; 513 } 514 515 function cleanupVoiceTypingOnPage() { 516 if (pageController) { 517 pageController.cleanup(); 518 pageController = null; 519 } 520 } 521 522 const doc = getDocument(); 523 if (doc) { 524 if (doc.readyState === "loading") { 525 doc.addEventListener("DOMContentLoaded", () => { 526 initVoiceTypingOnPage(); 527 }); 528 } else { 529 initVoiceTypingOnPage(); 530 } 531 global.addEventListener("pagehide", cleanupVoiceTypingOnPage); 532 } 533 534 root.voiceTyping = { 535 DEFAULT_LANGUAGE, 536 LABEL_IDLE, 537 LABEL_LISTENING, 538 getSpeechRecognitionCtor, 539 isSpeechRecognitionSupported, 540 insertTranscriptAtCursor, 541 mountSpeechInput, 542 resolveVoiceTypingField, 543 announceStatus, 544 initVoiceTypingOnPage, 545 cleanupVoiceTypingOnPage, 546 getActiveController() { 547 return activeController; 548 }, 549 }; 550})(typeof window !== "undefined" ? window : globalThis);
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.