PageSourceSearch

https://paterika.com/utils/voice-typing.js?v=1790540034535010819

js paterika.com collected 2026-10-10 01:39:41 UTC 16,498 bytes, 550 lines download raw bytes

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.