PageSourceSearch

https://www.dinslaken.de/themes/contrib/m310_www_theme/js/additional/spkbl.js

js dinslaken.de collected 2026-10-02 10:13:21 UTC 24,902 bytes, 713 lines download raw bytes

1/* Speakable Text-To-Speech player 0.2.0 | https://github.com/tollwerk/speakable */
2/* eslint no-param-reassign: ["error", { "props": false }] */
3(function iffe(w, d) {
4    /**
5     * Speech Synthesis Voices
6     *
7     * @type {SpeechSynthesisVoice[]}
8     */
9    var voices = [];
10    /**
11     * Global Speech Utterance
12     *
13     * @type {SpeechSynthesisUtterance}
14     */
15    var speechUtterance = null;
16    /**
17     * Regular expression to match punctuation
18     *
19     * @type {RegExp}
20     */
21    var punctuation = /[’'‘`“”"[\](){}…,.!;?\-:\u0964\u0965]\s*$/;
22    /**
23     * Characters that should be stripped from output
24     *
25     * @type {RegExp}
26     */
27    var dontspeak = /(\w)[·‧*:](\w)/gi;
28    /**
29     * Default options
30     *
31     * @type {{multivoice: boolean, selector: string,
32     * l18n: {play: string, stop: string, progress: string, pause: string}}}
33     */
34    var defaultOptions = {
35        selector: '.spkbl',
36        local: true,
37        multivoice: true,
38        hidden: false,
39        player: null,
40        l18n: {
41            play: 'Read text',
42            pause: 'Pause',
43            progress: 'Progress',
44            stop: 'Resume'
45        }
46    };
47    /**
48     * Block level elements
49     *
50     * @type {string[]}
51     */
52    var blockLevelElements = ['address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'dd', 'div', 'dl',
53        'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
54        'hgroup', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul'];
55    /**
56     * Test whether an element is a block level element
57     *
58     * @param {Element} element element
59     *
60     * @returns {boolean} Is block level element
61     */
62    function isBlockLevelElement(element) {
63        return blockLevelElements.indexOf(element.tagName.toLowerCase()) !== -1;
64    }
65    /**
66     * Simple object check
67     *
68     * @param item Item
69     *
70     * @returns {boolean} Is object
71     */
72    function isObject(item) {
73        return (item && typeof item === 'object' && !Array.isArray(item));
74    }
75    /**
76     * Deep merge multiple objects
77     *
78     * @param target Target object
79     * @param sources Source object(s)
80     */
81    function mergeDeep(target) {
82        var sources = [];
83        for (var _i = 1; _i < arguments.length; _i++) {
84            sources[_i - 1] = arguments[_i];
85        }
86        if (!sources.length) {
87            return target;
88        }
89        var source = sources.shift();
90        if (isObject(target) && isObject(source)) {
91            for (var key in source) {
92                if (Object.prototype.hasOwnProperty.call(source, key) && isObject(source[key])) {
93                    if (!target[key])
94                        Object.assign(target, (_a = {}, _a[key] = {}, _a));
95                    mergeDeep(target[key], source[key]);
96                }
97                else {
98                    Object.assign(target, (_b = {}, _b[key] = source[key], _b));
99                }
100            }
101        }
102        return mergeDeep.apply(void 0, [target].concat(sources));
103        var _a, _b;
104    }
105    /**
106     * Cast a value to a Boolean if possible
107     *
108     * @param {String} val Value
109     * @return {boolean|String} Converted value
110     */
111    function castToBool(val) {
112        if ((val === '1') || (val.toLowerCase() === 'true')) {
113            return true;
114        }
115        if ((val === '0') || (val.toLowerCase() === 'false')) {
116            return false;
117        }
118        return val;
119    }
120    /**
121     * Abstract syntax tree parser
122     *
123     * @param {String} language Language
124     * @param {Boolean} multivoice Multiple voices
125     *
126     * @constructor
127     */
128    function AstParser(language, multivoice) {
129        this.lang = language;
130        this.multivoice = multivoice;
131        this.items = [];
132    }
133    /**
134     * Recursively parse an element
135     *
136     * The method recursively traverses the DOM, collects readable elements, determines their language, filters out
137     * elements that should be skipped and extracts the readable text. The result is a hierarchichal structure of
138     * readable elements, stored in the parser's .items property, somewhat looking like this:
139     *
140     * items = [
141     *     {
142     *         lang: "en",
143     *         node: text, // DOM node reference
144     *         type: 0,
145     *         text: "Readable text"
146     *     },
147     *     {
148     *         lang: "en",
149     *         node: p, // DOM node reference
150     *         type: 2,
151     *         items: [...]
152     *     },
153     *     ...
154     * ]
155     *
156     * The "type" value specifies the item type:
157     *
158     * 0: Text node
159     * 1: Inline element
160     * 2: Block element
161     *
162     * @param {Element} element Element
163     *
164     * @returns {Object[]} Items
165     */
166    AstParser.prototype.parse = function parse(element) {
167        var _this = this;
168        element.childNodes.forEach(function (c) {
169            if (c.nodeType === Element.ELEMENT_NODE) {
170                if (!c.hasAttribute('data-spkbl-skip')) {
171                    var lang = _this.multivoice ? (c.lang || _this.lang) : _this.lang;
172                    _this.items.push({
173                        type: 1 + isBlockLevelElement(c),
174                        lang: lang,
175                        node: c,
176                        items: (new AstParser(lang, _this.multivoice)).parse(c)
177                    });
178                }
179            }
180            else if (c.nodeType === Element.TEXT_NODE) {
181                var text = c.nodeValue;
182                if (text.trim().length) {
183                    text = text.replace(/[\s\r\n]+/g, ' ');
184                    text = text.replace(dontspeak, '$1$2');
185                    _this.items.push({
186                        type: 0,
187                        lang: _this.lang,
188                        node: c,
189                        text: text
190                    });
191                }
192            }
193        });
194        return this.items;
195    };
196    /**
197     * Create a new sentence
198     *
199     * @param {String} lang Language
200     *
201     * @return {{chunks: [], lang: *}}
202     */
203    AstParser.prototype.createSentence = function createSentence(lang) {
204        return {
205            lang: lang,
206            chunks: []
207        };
208    };
209    /**
210     * Parse an element into readable chunks
211     *
212     * @param {Element} element Element
213     *
214     * @returns {Array} Readable chunks
215     * @private
216     */
217    AstParser.prototype.createChunks = function createChunks(element) {
218        var _this = this;
219        var chunks = [];
220        var sentence = null;
221        var chunksRecursive = function (c) {
222            if (sentence === null) {
223                sentence = _this.createSentence(c.lang);
224                if (c.type) {
225                    c.items.forEach(chunksRecursive);
226                    if (sentence && sentence.chunks.length) {
227                        chunks.push(sentence);
228                    }
229                    sentence = null;
230                }
231                else {
232                    sentence.chunks.push({
233                        node: c.node,
234                        text: c.text
235                    });
236                }
237            }
238            else {
239                switch (c.type) {
240                    case 2:
241                        if (sentence.chunks.length) {
242                            chunks.push(sentence);
243                            sentence = _this.createSentence(c.lang);
244                        }
245                        else {
246                            sentence.lang = c.lang;
247                        }
248                        c.items.forEach(chunksRecursive);
249                        if (sentence && sentence.chunks.length) {
250                            chunks.push(sentence);
251                        }
252                        sentence = null;
253                        break;
254                    case 1:
255                        if (c.node.tagName.toUpperCase() === 'BR') {
256                            var clen = sentence.chunks.length;
257                            if (clen) {
258                                var lastText = sentence.chunks[clen - 1].text;
259                                if (!punctuation.test(lastText)) {
260                                    sentence.chunks.push({
261                                        node: c.node,
262                                        text: ' . '
263                                    });
264                                }
265                                else if (!/\s$/.test(lastText)) {
266                                    sentence.chunks.push({
267                                        node: c.node,
268                                        text: ' '
269                                    });
270                                }
271                            }
272                        }
273                        else if (c.lang === sentence.lang) {
274                            c.items.forEach(chunksRecursive);
275                        }
276                        else {
277                            var lang = sentence.lang;
278                            if (sentence.chunks.length) {
279                                chunks.push(sentence);
280                            }
281                            sentence = _this.createSentence(c.lang);
282                            c.items.forEach(chunksRecursive);
283                            if (sentence.chunks.length) {
284                                chunks.push(sentence);
285                            }
286                            sentence = _this.createSentence(lang);
287                        }
288                        break;
289                    default:
290                        sentence.chunks.push({
291                            node: c.node,
292                            text: c.text
293                        });
294                }
295            }
296        };
297        this.parse(element)
298            .forEach(chunksRecursive);
299        if (sentence && sentence.chunks.length) {
300            chunks[chunks.length] = sentence;
301        }
302        return chunks;
303    };
304    /**
305     * Collapse a chunk's text nodes and build a node source map
306     *
307     * @param {Object} chunk Chunk
308     *
309     * @return {{sourcemap: {}, text: string, lang: string}}
310     */
311    AstParser.prototype.map = function map(chunk) {
312        var mappedChunk = {
313            lang: chunk.lang,
314            text: '',
315            map: new Map()
316        };
317        chunk.chunks.forEach(function (c) {
318            // const wrap = d.createElementNS('https://tollwerk.de/speakable/1.0', 's:s');
319            // c.node.after(wrap);
320            // wrap.appendChild(c.node);
321            // mappedChunk.map.set(mappedChunk.text.length, wrap);
322            mappedChunk.map.set(mappedChunk.text.length, c.node);
323            mappedChunk.text += c.text;
324        });
325        return mappedChunk;
326    };
327    /**
328     * Parse an element and return consolidated readable chunks
329     *
330     * @param {Element} element Element
331     *
332     * @returns {Array} Readable chunks
333     */
334    AstParser.prototype.chunked = function chunked(element) {
335        var chunks = this.createChunks(element);
336        if (!chunks.length) {
337            return [];
338        }
339        // Run through all chunks, collapse the text nodes and build corresponding sourcemaps
340        var chunkMaps = chunks.map(this.map);
341        var consolidated = [chunkMaps.shift()];
342        var _loop_1 = function() {
343            var chunk = chunkMaps.shift();
344            var last = consolidated.length - 1;
345            if (chunk.lang === consolidated[last].lang) {
346                if (!punctuation.test(consolidated[last].text)) {
347                    consolidated[last].text += '. ';
348                }
349                consolidated[last].text = consolidated[last].text.trim() + " ";
350                var offset_1 = consolidated[last].text.length;
351                consolidated[last].text += chunk.text;
352                chunk.map.forEach(function (value, key) {
353                    consolidated[last].map.set(offset_1 + key, value);
354                });
355            }
356            else {
357                consolidated.push(chunk);
358            }
359        };
360        while (chunkMaps.length) {
361            _loop_1();
362        }
363        return consolidated;
364    };
365    /**
366     * Speakable
367     *
368     * @param {Element} element Speakable
369     * @param {Object} options Options
370     *
371     * @constructor
372     */
373    function Speakable(element, options) {
374        this.element = element;
375        this.options = this.configure(options, 'data-spkbl');
376        this.utterances = [];
377        this.currentUtterance = 0;
378        this.length = 0;
379        this.offset = 0;
380        this.progress = 0;
381        this.paused = false;
382        this.nextOnResume = false;
383        this.player = null;
384        this.controls = {};
385        var factory = this.defaultPlayer;
386        if (this.options.player) {
387            if (typeof this.options.player === 'function') {
388                factory = this.options.player;
389            }
390            else if (typeof w[this.options.player] === 'function') {
391                factory = w[this.options.player];
392            }
393        }
394        this.buildPlayer(factory);
395        // Parse the element contents
396        var astParser = new AstParser(this.determineLanguage(this.element) || 'en', this.options.multivoice);
397        this.setUtterances(astParser.chunked(this.element));
398        // Inject the player
399        this.injectPlayer();
400    }
401    /**
402     * Configure this instance by data attributes
403     *
404     * @param {Object} options Options
405     * @param {String} prefix Attribute prefix
406     *
407     * @private
408     */
409    Speakable.prototype.configure = function configure(options, prefix) {
410        var configured = {};
411        for (var o in options) {
412            if (Object.prototype.hasOwnProperty.call(options, o)) {
413                var attr = prefix + "-" + o;
414                if (isObject(options[o])) {
415                    configured[o] = this.configure(options[o], attr);
416                }
417                else if (this.element.hasAttribute(attr)) {
418                    configured[o] = castToBool(this.element.getAttribute(attr));
419                }
420                else {
421                    configured[o] = options[o];
422                }
423            }
424        }
425        return configured;
426    };
427    /**
428     * Determine element language
429     *
430     * @param {Element} element Element
431     *
432     * @private
433     */
434    Speakable.prototype.determineLanguage = function determineLanguage(element) {
435        var lang = element.lang;
436        return lang || (element.parentNode ? this.determineLanguage(element.parentNode) : null);
437    };
438    /**
439     * Create the default player
440     *
441     * @param {Speakable} spkbl Speakable reference
442     * @return {Object} Player elements
443     * @private
444     */
445    Speakable.prototype.defaultPlayer = function defaultPlayer(spkbl) {
446        var player = {
447            player: d.createElement('div'),
448            controls: {}
449        };
450        player.controls.play = player.player.appendChild(d.createElement('button'));
451        player.controls.play.innerHTML = spkbl.options.l18n.play;
452        player.controls.pause = player.player.appendChild(d.createElement('button'));
453        player.controls.pause.innerHTML = spkbl.options.l18n.pause;
454        player.controls.progress = player.player.appendChild(d.createElement('progress'));
455        player.controls.progress.innerHTML = '0%';
456        player.controls.stop = player.player.appendChild(d.createElement('button'));
457        player.controls.stop.innerHTML = spkbl.options.l18n.stop;
458        return player;
459    };
460    /**
461     * Build the player
462     *
463     * @param {Function} factory Player factory
464     * @private
465     */
466    Speakable.prototype.buildPlayer = function buildPlayer(factory) {
467        var instance = factory(this);
468        this.player = instance.player;
469        this.controls = instance.controls;
470        this.player.classList.add('spkbl-player', 'spkbl-player--inactive');
471        this.player.role = 'group';
472        if (this.options.hidden) {
473            this.player.setAttribute('aria-hidden', 'true');
474        }
475        // Play button
476        this.controls.play.type = 'button';
477        this.controls.play.classList.add('spkbl-ctrl', 'spkbl-ctrl--play');
478        this.controls.play.addEventListener('click', this.play.bind(this));
479        // Pause button
480        this.controls.pause.type = 'button';
481        this.controls.pause.classList.add('spkbl-ctrl', 'spkbl-ctrl--pause');
482        this.controls.pause.addEventListener('click', this.pause.bind(this));
483        this.controls.pause.setAttribute('aria-pressed', 'false');
484        // Progress bar
485        this.controls.progress.classList.add('spkbl-ctrl', 'spkbl-ctrl--progress');
486        this.controls.progress.max = '100';
487        this.controls.progress.value = '0';
488        this.controls.progress.setAttribute('aria-label', this.options.l18n.progress);
489        this.controls.progress.setAttribute('aria-valuenow', '0');
490        this.controls.progress.setAttribute('aria-valuemin', '0');
491        this.controls.progress.setAttribute('aria-valuemax', '100');
492        this.controls.progress.setAttribute('role', 'progressbar');
493        // Stop button
494        this.controls.stop.type = 'button';
495        this.controls.stop.classList.add('spkbl-ctrl', 'spkbl-ctrl--stop');
496        this.controls.stop.addEventListener('click', this.stop.bind(this));
497    };
498    /**
499     * Start playing
500     *
501     * @param {Array} utterances Utterances
502     *
503     * @private
504     */
505    Speakable.prototype.setUtterances = function setUtterances(utterances) {
506        var _this = this;
507        this.length = 0;
508        this.utterances = utterances.map(function (u) {
509            u.length = u.text.length;
510            _this.length += u.length + 1;
511            return u;
512        });
513        this.length += 1;
514    };
515    /**
516     * Start playing
517     *
518     * @param {SpeechSynthesisEvent} e Event
519     */
520    Speakable.prototype.play = function play(e) {
521        if (Speakable.current) {
522            Speakable.current.halt();
523        }
524        Speakable.current = this;
525        this.player.classList.add('spkbl-player--active');
526        this.player.classList.remove('spkbl-player--inactive');
527        this.controls.pause.focus();
528        d.addEventListener('keyup', this.escape.bind(this));
529        this.currentUtterance = -1;
530        this.offset = 0;
531        this.progress = 0;
532        speechSynthesis.cancel();
533        // console.log(speechUtterance);
534        speechUtterance.onboundary = this.boundary.bind(this);
535        speechUtterance.onend = this.next.bind(this);
536        this.next(e);
537    };
538    /**
539     * Escape the player
540     *
541     * @param {KeyboardEvent} e Event
542     */
543    Speakable.prototype.escape = function escape(e) {
544        var evt = e || window.event;
545        if (('key' in evt) ? (evt.key === 'Escape' || evt.key === 'Esc') : (evt.keyCode === 27)) {
546            this.stop();
547        }
548    };
549    /**
550     * Play the next utterance
551     *
552     * @param {SpeechSynthesisEvent} e Event
553     */
554    Speakable.prototype.next = function next(e) {
555        if (this.paused) {
556            this.nextOnResume = true;
557            speechSynthesis.cancel();
558        }
559        else if (this.utterances.length > (this.currentUtterance + 1)) {
560            if (this.currentUtterance >= 0) {
561                this.offset += this.utterances[this.currentUtterance].length + 1;
562            }
563            this.currentUtterance += 1;
564            var utterance = this.utterances[this.currentUtterance];
565            speechUtterance.text = utterance.text;
566            speechUtterance.voice = this.getUtteranceVoice(utterance);
567            speechSynthesis.speak(speechUtterance);
568        }
569        else {
570            this.stop(e);
571        }
572    };
573    /**
574     * Find the voice for an utterance
575     *
576     * @param {Object} utterance Utterance
577     *
578     * @returns {SpeechSynthesisVoice} Voice
579     *
580     * @private
581     */
582    Speakable.prototype.getUtteranceVoice = function getUtteranceVoice(utterance) {
583        if (!utterance.voice) {
584            var locale_1 = utterance.lang;
585            var lang_1 = locale_1.split('-')
586                .shift();
587            utterance.voice = voices.find(function (v) { return (v.lang === locale_1) || (v.lang === lang_1)
588                || v.lang.startsWith(locale_1 + "-") || v.lang.startsWith(lang_1 + "-"); })
589                || voices.find(function (v) { return v.default; }) || voices[0];
590        }
591        return utterance.voice;
592    };
593    /**
594     * Boundary handler
595     *
596     * @param {SpeechSynthesisEvent} e Event
597     */
598    Speakable.prototype.boundary = function boundary(e) {
599        this.progress = Math.round((100 * (this.offset + e.charIndex)) / this.length);
600        this.controls.progress.value = this.progress;
601        this.controls.progress.setAttribute('aria-valuenow', this.progress);
602        this.controls.progress.textContent = this.progress + " % ";
603        // console.debug(this.progress, e.name, speechUtterance.text.substr(e.charIndex, e.charLength));
604    };
605    /**
606     * Pause / Resume playing
607     */
608    Speakable.prototype.pause = function pause() {
609        speechSynthesis[this.togglePause(this.paused) ? 'pause' : 'resume']();
610        if (this.nextOnResume) {
611            this.nextOnResume = false;
612            this.next();
613        }
614    };
615    /**
616     * Toggle pause button
617     *
618     * @var {Boolean} paused Is paused
619     *
620     * @return {Boolean} Is paused
621     */
622    Speakable.prototype.togglePause = function togglePause(paused) {
623        this.paused = !paused;
624        this.player.classList[paused ? 'remove' : 'add']('spkbl-player--paused');
625        this.controls.pause.setAttribute('aria-pressed', paused ? 'false' : 'true');
626        return this.paused;
627    };
628    /**
629     * Stop playing and reset player
630     */
631    Speakable.prototype.stop = function stop() {
632        this.halt();
633        this.controls.play.focus();
634    };
635    /**
636     * Stop playing
637     */
638    Speakable.prototype.halt = function halt() {
639        speechUtterance.onboundary = null;
640        speechUtterance.onend = null;
641        speechSynthesis.cancel();
642        this.togglePause(true);
643        d.removeEventListener('keyup', this.escape.bind(this));
644        this.player.classList.add('spkbl-player--inactive');
645        this.player.classList.remove('spkbl-player--active');
646        this.player.classList.remove('spkbl-player--paused');
647    };
648    /**
649     * Inject the player
650     *
651     * @private
652     */
653    Speakable.prototype.injectPlayer = function injectPlayer() {
654        if (typeof this.options.insert === 'function') {
655            this.options.insert(this.element, this.player);
656            return;
657        }
658        switch (this.options.insert) {
659            case 'before':
660                this.element.parentNode.insertBefore(this.player, this.element);
661                break;
662            case 'after':
663                this.element.parentNode.insertBefore(this.player, this.element.nextSibling);
664                break;
665            default:
666                this.element.insertBefore(this.player, this.element.firstChild);
667        }
668    };
669    /**
670     * Currently active player
671     *
672     * @type {Speakable}
673     */
674    Speakable.current = null;
675    /**
676     * Initialize all speakables
677     *
678     * @param {Object} options Options
679     *
680     * @returns {Array} Speakables
681     */
682    Speakable.init = function init(options) {
683        if (options === void 0) { options = {}; }
684        // If the Web Speech API is supported
685        if ('SpeechSynthesisUtterance' in w) {
686            // Prepare the options
687            var opts_1 = mergeDeep(defaultOptions, options);
688            var selector = opts_1.selector || '';
689            delete opts_1.selector;
690            // Prepare the voices
691            speechUtterance = new SpeechSynthesisUtterance();
692            speechUtterance.volume = 1;
693            speechUtterance.pitch = 1;
694            speechUtterance.rate = 1;
695            voices = speechSynthesis.getVoices().filter(function (v) { return !opts_1.local || v.localService; });
696            // Safari iOS doesn't support the addEventListener() method for the speechSynthesis
697            if (speechSynthesis.addEventListener) {
698                speechSynthesis.addEventListener('voiceschanged', function () {
699                    voices = speechSynthesis.getVoices().filter(function (v) { return !opts_1.local || v.localService; });
700                });
701            }
702            return selector.length ? Array.from(d.querySelectorAll(selector))
703                .map(function (s) { return new Speakable(s, opts_1); }) : [];
704        }
705        return [];
706    };
707    if (typeof exports !== 'undefined') {
708        exports.Speakable = Speakable;
709    }
710    else {
711        w.Speakable = Speakable;
712    }
713}(typeof global !== 'undefined' ? global : window, document));

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.