diff options
author | Matt A. Tobin <email@mattatobin.com> | 2021-11-14 01:30:12 -0500 |
---|---|---|
committer | Matt A. Tobin <email@mattatobin.com> | 2021-11-14 01:30:12 -0500 |
commit | 43fa0a71c814e902165782680f2f9705e3c234c9 (patch) | |
tree | 0ab3c66e8432febc00c18c036706c7ff43c11799 /components/narrate | |
parent | 2fccdffac3075ee72087f9ee153204b02c7d931a (diff) | |
download | aura-central-43fa0a71c814e902165782680f2f9705e3c234c9.tar.gz |
Issue %3005 - Move toolkit/components to components/
Diffstat (limited to 'components/narrate')
-rw-r--r-- | components/narrate/.eslintrc.js | 94 | ||||
-rw-r--r-- | components/narrate/NarrateControls.jsm | 313 | ||||
-rw-r--r-- | components/narrate/Narrator.jsm | 440 | ||||
-rw-r--r-- | components/narrate/VoiceSelect.jsm | 300 | ||||
-rw-r--r-- | components/narrate/moz.build | 10 |
5 files changed, 1157 insertions, 0 deletions
diff --git a/components/narrate/.eslintrc.js b/components/narrate/.eslintrc.js new file mode 100644 index 000000000..b2d443575 --- /dev/null +++ b/components/narrate/.eslintrc.js @@ -0,0 +1,94 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": [ + "../../.eslintrc.js" + ], + + "globals": { + "Components": true, + "dump": true, + "Iterator": true + }, + + "env": { "browser": true }, + + "rules": { + // Mozilla stuff + "mozilla/no-aArgs": "warn", + "mozilla/reject-importGlobalProperties": "warn", + "mozilla/var-only-at-top-level": "warn", + + "block-scoped-var": "error", + "brace-style": ["warn", "1tbs", {"allowSingleLine": false}], + "camelcase": "warn", + "comma-dangle": "off", + "comma-spacing": ["warn", {"before": false, "after": true}], + "comma-style": ["warn", "last"], + "complexity": "warn", + "consistent-return": "error", + "curly": "error", + "dot-location": ["warn", "property"], + "dot-notation": "error", + "eol-last": "error", + "generator-star-spacing": ["warn", "after"], + "indent": ["warn", 2, {"SwitchCase": 1}], + "key-spacing": ["warn", {"beforeColon": false, "afterColon": true}], + "keyword-spacing": "warn", + "max-len": ["warn", 80, 2, {"ignoreUrls": true}], + "max-nested-callbacks": ["error", 3], + "new-cap": ["error", {"capIsNew": false}], + "new-parens": "error", + "no-array-constructor": "error", + "no-cond-assign": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-else-return": "error", + "no-eval": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-boolean-cast": "error", + "no-extra-semi": "warn", + "no-fallthrough": "error", + "no-inline-comments": "warn", + "no-lonely-if": "error", + "no-mixed-spaces-and-tabs": "error", + "no-multi-spaces": "warn", + "no-multi-str": "warn", + "no-multiple-empty-lines": ["warn", {"max": 1}], + "no-native-reassign": "error", + "no-nested-ternary": "error", + "no-redeclare": "error", + "no-return-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow": "warn", + "no-shadow-restricted-names": "error", + "no-spaced-func": "warn", + "no-throw-literal": "error", + "no-trailing-spaces": "error", + "no-undef": "error", + "no-unneeded-ternary": "error", + "no-unreachable": "error", + "no-unused-vars": "error", + "no-with": "error", + "padded-blocks": ["warn", "never"], + "quotes": ["warn", "double", "avoid-escape"], + "semi": ["warn", "always"], + "semi-spacing": ["warn", {"before": false, "after": true}], + "space-before-blocks": ["warn", "always"], + "space-before-function-paren": ["warn", "never"], + "space-in-parens": ["warn", "never"], + "space-infix-ops": ["warn", {"int32Hint": true}], + "space-unary-ops": ["warn", { "words": true, "nonwords": false }], + "spaced-comment": ["warn", "always"], + "strict": ["error", "global"], + "use-isnan": "error", + "valid-typeof": "error", + "yoda": "error" + } +}; diff --git a/components/narrate/NarrateControls.jsm b/components/narrate/NarrateControls.jsm new file mode 100644 index 000000000..56b3deaf8 --- /dev/null +++ b/components/narrate/NarrateControls.jsm @@ -0,0 +1,313 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/narrate/VoiceSelect.jsm"); +Cu.import("resource://gre/modules/narrate/Narrator.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/AsyncPrefs.jsm"); + +this.EXPORTED_SYMBOLS = ["NarrateControls"]; + +var gStrings = Services.strings.createBundle("chrome://global/locale/narrate.properties"); + +function NarrateControls(win, languagePromise) { + this._winRef = Cu.getWeakReference(win); + this._languagePromise = languagePromise; + + win.addEventListener("unload", this); + + // Append content style sheet in document head + let style = win.document.createElement("link"); + style.rel = "stylesheet"; + style.href = "chrome://global/skin/narrate.css"; + win.document.head.appendChild(style); + + function localize(pieces, ...substitutions) { + let result = pieces[0]; + for (let i = 0; i < substitutions.length; ++i) { + result += gStrings.GetStringFromName(substitutions[i]) + pieces[i + 1]; + } + return result; + } + + let dropdown = win.document.createElement("ul"); + dropdown.className = "dropdown narrate-dropdown"; + // We need inline svg here for the animation to work (bug 908634 & 1190881). + // eslint-disable-next-line no-unsanitized/property + dropdown.innerHTML = + localize`<li> + <button class="dropdown-toggle button narrate-toggle" + title="${"narrate"}" hidden> + <svg xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + width="24" height="24" viewBox="0 0 24 24"> + <style> + @keyframes grow { + 0% { transform: scaleY(1); } + 15% { transform: scaleY(1.5); } + 15% { transform: scaleY(1.5); } + 30% { transform: scaleY(1); } + 100% { transform: scaleY(1); } + } + + .waveform > rect { + fill: #808080; + } + + .speaking .waveform > rect { + fill: #58bf43; + transform-box: fill-box; + transform-origin: 50% 50%; + animation-name: grow; + animation-duration: 1750ms; + animation-iteration-count: infinite; + animation-timing-function: linear; + } + + .waveform > rect:nth-child(2) { animation-delay: 250ms; } + .waveform > rect:nth-child(3) { animation-delay: 500ms; } + .waveform > rect:nth-child(4) { animation-delay: 750ms; } + .waveform > rect:nth-child(5) { animation-delay: 1000ms; } + .waveform > rect:nth-child(6) { animation-delay: 1250ms; } + .waveform > rect:nth-child(7) { animation-delay: 1500ms; } + + </style> + <g class="waveform"> + <rect x="1" y="8" width="2" height="8" rx=".5" ry=".5" /> + <rect x="4" y="5" width="2" height="14" rx=".5" ry=".5" /> + <rect x="7" y="8" width="2" height="8" rx=".5" ry=".5" /> + <rect x="10" y="4" width="2" height="16" rx=".5" ry=".5" /> + <rect x="13" y="2" width="2" height="20" rx=".5" ry=".5" /> + <rect x="16" y="4" width="2" height="16" rx=".5" ry=".5" /> + <rect x="19" y="7" width="2" height="10" rx=".5" ry=".5" /> + </g> + </svg> + </button> + </li> + <li class="dropdown-popup"> + <div class="narrate-row narrate-control"> + <button disabled class="narrate-skip-previous" + title="${"back"}"></button> + <button class="narrate-start-stop" title="${"start"}"></button> + <button disabled class="narrate-skip-next" + title="${"forward"}"></button> + </div> + <div class="narrate-row narrate-rate"> + <input class="narrate-rate-input" value="0" title="${"speed"}" + step="5" max="100" min="-100" type="range"> + </div> + <div class="narrate-row narrate-voices"></div> + <div class="dropdown-arrow"></div> + </li>`; + + this.narrator = new Narrator(win, languagePromise); + + let branch = Services.prefs.getBranch("narrate."); + let selectLabel = gStrings.GetStringFromName("selectvoicelabel"); + this.voiceSelect = new VoiceSelect(win, selectLabel); + this.voiceSelect.element.addEventListener("change", this); + this.voiceSelect.element.classList.add("voice-select"); + win.speechSynthesis.addEventListener("voiceschanged", this); + dropdown.querySelector(".narrate-voices").appendChild( + this.voiceSelect.element); + + dropdown.addEventListener("click", this, true); + + let rateRange = dropdown.querySelector(".narrate-rate > input"); + rateRange.addEventListener("change", this); + + // The rate is stored as an integer. + rateRange.value = branch.getIntPref("rate"); + + this._setupVoices(); + + let tb = win.document.querySelector(".reader-toolbar"); + tb.appendChild(dropdown); +} + +NarrateControls.prototype = { + handleEvent(evt) { + switch (evt.type) { + case "change": + if (evt.target.classList.contains("narrate-rate-input")) { + this._onRateInput(evt); + } else { + this._onVoiceChange(); + } + break; + case "click": + this._onButtonClick(evt); + break; + case "voiceschanged": + this._setupVoices(); + break; + } + }, + + /** + * Returns true if synth voices are available. + */ + _setupVoices() { + return this._languagePromise.then(language => { + this.voiceSelect.clear(); + let win = this._win; + let voicePrefs = this._getVoicePref(); + let selectedVoice = voicePrefs[language || "default"]; + let comparer = win.Intl ? + (new Intl.Collator()).compare : (a, b) => a.localeCompare(b); + let filter = !Services.prefs.getBoolPref("narrate.filter-voices"); + let options = win.speechSynthesis.getVoices().filter(v => { + return filter || !language || v.lang.split("-")[0] == language; + }).map(v => { + return { + label: this._createVoiceLabel(v), + value: v.voiceURI, + selected: selectedVoice == v.voiceURI + }; + }).sort((a, b) => comparer(a.label, b.label)); + + if (options.length) { + options.unshift({ + label: gStrings.GetStringFromName("defaultvoice"), + value: "automatic", + selected: selectedVoice == "automatic" + }); + this.voiceSelect.addOptions(options); + } + + let narrateToggle = win.document.querySelector(".narrate-toggle"); + let initial = !this._voicesInitialized; + this._voicesInitialized = true; + + // We disable this entire feature if there are no available voices. + narrateToggle.hidden = !options.length; + }); + }, + + _getVoicePref() { + let voicePref = Services.prefs.getCharPref("narrate.voice"); + try { + return JSON.parse(voicePref); + } catch (e) { + return { default: voicePref }; + } + }, + + _onRateInput(evt) { + AsyncPrefs.set("narrate.rate", parseInt(evt.target.value, 10)); + this.narrator.setRate(this._convertRate(evt.target.value)); + }, + + _onVoiceChange() { + let voice = this.voice; + this.narrator.setVoice(voice); + this._languagePromise.then(language => { + if (language) { + let voicePref = this._getVoicePref(); + voicePref[language || "default"] = voice; + AsyncPrefs.set("narrate.voice", JSON.stringify(voicePref)); + } + }); + }, + + _onButtonClick(evt) { + let classList = evt.target.classList; + if (classList.contains("narrate-skip-previous")) { + this.narrator.skipPrevious(); + } else if (classList.contains("narrate-skip-next")) { + this.narrator.skipNext(); + } else if (classList.contains("narrate-start-stop")) { + if (this.narrator.speaking) { + this.narrator.stop(); + } else { + this._updateSpeechControls(true); + let options = { rate: this.rate, voice: this.voice }; + this.narrator.start(options).then(() => { + this._updateSpeechControls(false); + }, err => { + Cu.reportError(`Narrate failed: ${err}.`); + this._updateSpeechControls(false); + }); + } + } + }, + + _updateSpeechControls(speaking) { + let dropdown = this._doc.querySelector(".narrate-dropdown"); + dropdown.classList.toggle("keep-open", speaking); + dropdown.classList.toggle("speaking", speaking); + + let startStopButton = this._doc.querySelector(".narrate-start-stop"); + startStopButton.title = + gStrings.GetStringFromName(speaking ? "stop" : "start"); + + this._doc.querySelector(".narrate-skip-previous").disabled = !speaking; + this._doc.querySelector(".narrate-skip-next").disabled = !speaking; + }, + + _createVoiceLabel(voice) { + // This is a highly imperfect method of making human-readable labels + // for system voices. Because each platform has a different naming scheme + // for voices, we use a different method for each platform. + switch (Services.appinfo.OS) { + case "WINNT": + // On windows the language is included in the name, so just use the name + return voice.name; + case "Linux": + // On Linux, the name is usually the unlocalized language name. + // Use a localized language name, and have the language tag in + // parenthisis. This is to avoid six languages called "English". + return gStrings.formatStringFromName("voiceLabel", + [this._getLanguageName(voice.lang) || voice.name, voice.lang], 2); + default: + // On Mac the language is not included in the name, find a localized + // language name or show the tag if none exists. + // This is the ideal naming scheme so it is also the "default". + return gStrings.formatStringFromName("voiceLabel", + [voice.name, this._getLanguageName(voice.lang) || voice.lang], 2); + } + }, + + _getLanguageName(lang) { + if (!this._langStrings) { + this._langStrings = Services.strings.createBundle( + "chrome://global/locale/languageNames.properties "); + } + + try { + // language tags will be lower case ascii between 2 and 3 characters long. + return this._langStrings.GetStringFromName(lang.match(/^[a-z]{2,3}/)[0]); + } catch (e) { + return ""; + } + }, + + _convertRate(rate) { + // We need to convert a relative percentage value to a fraction rate value. + // eg. -100 is half the speed, 100 is twice the speed in percentage, + // 0.5 is half the speed and 2 is twice the speed in fractions. + return Math.pow(Math.abs(rate / 100) + 1, rate < 0 ? -1 : 1); + }, + + get _win() { + return this._winRef.get(); + }, + + get _doc() { + return this._win.document; + }, + + get rate() { + return this._convertRate( + this._doc.querySelector(".narrate-rate-input").value); + }, + + get voice() { + return this.voiceSelect.value; + } +}; diff --git a/components/narrate/Narrator.jsm b/components/narrate/Narrator.jsm new file mode 100644 index 000000000..ac0b2e040 --- /dev/null +++ b/components/narrate/Narrator.jsm @@ -0,0 +1,440 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +this.EXPORTED_SYMBOLS = [ "Narrator" ]; + +// Maximum time into paragraph when pressing "skip previous" will go +// to previous paragraph and not the start of current one. +const PREV_THRESHOLD = 2000; +// All text-related style rules that we should copy over to the highlight node. +const kTextStylesRules = ["font-family", "font-kerning", "font-size", + "font-size-adjust", "font-stretch", "font-variant", "font-weight", + "line-height", "letter-spacing", "text-orientation", + "text-transform", "word-spacing"]; + +function Narrator(win, languagePromise) { + this._winRef = Cu.getWeakReference(win); + this._languagePromise = languagePromise; + this._inTest = Services.prefs.getBoolPref("narrate.test"); + this._speechOptions = {}; + this._startTime = 0; + this._stopped = false; +} + +Narrator.prototype = { + get _doc() { + return this._winRef.get().document; + }, + + get _win() { + return this._winRef.get(); + }, + + get _treeWalker() { + if (!this._treeWalkerRef) { + let wu = this._win.QueryInterface( + Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + let nf = this._win.NodeFilter; + + let filter = { + _matches: new Set(), + + // We want high-level elements that have non-empty text nodes. + // For example, paragraphs. But nested anchors and other elements + // are not interesting since their text already appears in their + // parent's textContent. + acceptNode(node) { + if (this._matches.has(node.parentNode)) { + // Reject sub-trees of accepted nodes. + return nf.FILTER_REJECT; + } + + if (!/\S/.test(node.textContent)) { + // Reject nodes with no text. + return nf.FILTER_REJECT; + } + + let bb = wu.getBoundsWithoutFlushing(node); + if (!bb.width || !bb.height) { + // Skip non-rendered nodes. We don't reject because a zero-sized + // container can still have visible, "overflowed", content. + return nf.FILTER_SKIP; + } + + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.nodeType == c.TEXT_NODE && /\S/.test(c.textContent)) { + // If node has a non-empty text child accept it. + this._matches.add(node); + return nf.FILTER_ACCEPT; + } + } + + return nf.FILTER_SKIP; + } + }; + + this._treeWalkerRef = new WeakMap(); + + // We can't hold a weak reference on the treewalker, because there + // are no other strong references, and it will be GC'ed. Instead, + // we rely on the window's lifetime and use it as a weak reference. + this._treeWalkerRef.set(this._win, + this._doc.createTreeWalker(this._doc.querySelector(".container"), + nf.SHOW_ELEMENT, filter, false)); + } + + return this._treeWalkerRef.get(this._win); + }, + + get _timeIntoParagraph() { + let rv = Date.now() - this._startTime; + return rv; + }, + + get speaking() { + return this._win.speechSynthesis.speaking || + this._win.speechSynthesis.pending; + }, + + _getVoice(voiceURI) { + if (!this._voiceMap || !this._voiceMap.has(voiceURI)) { + this._voiceMap = new Map( + this._win.speechSynthesis.getVoices().map(v => [v.voiceURI, v])); + } + + return this._voiceMap.get(voiceURI); + }, + + _isParagraphInView(paragraph) { + if (!paragraph) { + return false; + } + + let bb = paragraph.getBoundingClientRect(); + return bb.top >= 0 && bb.top < this._win.innerHeight; + }, + + _sendTestEvent(eventType, detail) { + let win = this._win; + win.dispatchEvent(new win.CustomEvent(eventType, + { detail: Cu.cloneInto(detail, win.document) })); + }, + + _speakInner() { + this._win.speechSynthesis.cancel(); + let tw = this._treeWalker; + let paragraph = tw.currentNode; + if (paragraph == tw.root) { + this._sendTestEvent("paragraphsdone", {}); + return Promise.resolve(); + } + + let utterance = new this._win.SpeechSynthesisUtterance( + paragraph.textContent); + utterance.rate = this._speechOptions.rate; + if (this._speechOptions.voice) { + utterance.voice = this._speechOptions.voice; + } else { + utterance.lang = this._speechOptions.lang; + } + + this._startTime = Date.now(); + + let highlighter = new Highlighter(paragraph); + + if (this._inTest) { + let onTestSynthEvent = e => { + if (e.detail.type == "boundary") { + let args = Object.assign({ utterance }, e.detail.args); + let evt = new this._win.SpeechSynthesisEvent(e.detail.type, args); + utterance.dispatchEvent(evt); + } + }; + + let removeListeners = () => { + this._win.removeEventListener("testsynthevent", onTestSynthEvent); + }; + + this._win.addEventListener("testsynthevent", onTestSynthEvent); + utterance.addEventListener("end", removeListeners); + utterance.addEventListener("error", removeListeners); + } + + return new Promise((resolve, reject) => { + utterance.addEventListener("start", () => { + paragraph.classList.add("narrating"); + let bb = paragraph.getBoundingClientRect(); + if (bb.top < 0 || bb.bottom > this._win.innerHeight) { + paragraph.scrollIntoView({ behavior: "smooth", block: "start"}); + } + + if (this._inTest) { + this._sendTestEvent("paragraphstart", { + voice: utterance.chosenVoiceURI, + rate: utterance.rate, + paragraph: paragraph.textContent, + tag: paragraph.localName + }); + } + }); + + utterance.addEventListener("end", () => { + if (!this._win) { + // page got unloaded, don't do anything. + return; + } + + highlighter.remove(); + paragraph.classList.remove("narrating"); + this._startTime = 0; + if (this._inTest) { + this._sendTestEvent("paragraphend", {}); + } + + if (this._stopped) { + // User pressed stopped. + resolve(); + } else { + tw.currentNode = tw.nextNode() || tw.root; + this._speakInner().then(resolve, reject); + } + }); + + utterance.addEventListener("error", () => { + reject("speech synthesis failed"); + }); + + utterance.addEventListener("boundary", e => { + if (e.name != "word") { + // We are only interested in word boundaries for now. + return; + } + + if (e.charLength) { + highlighter.highlight(e.charIndex, e.charLength); + if (this._inTest) { + this._sendTestEvent("wordhighlight", { + start: e.charIndex, + end: e.charIndex + e.charLength + }); + } + } + }); + + this._win.speechSynthesis.speak(utterance); + }); + }, + + start(speechOptions) { + this._speechOptions = { + rate: speechOptions.rate, + voice: this._getVoice(speechOptions.voice) + }; + + this._stopped = false; + return this._languagePromise.then(language => { + if (!this._speechOptions.voice) { + this._speechOptions.lang = language; + } + + let tw = this._treeWalker; + if (!this._isParagraphInView(tw.currentNode)) { + tw.currentNode = tw.root; + while (tw.nextNode()) { + if (this._isParagraphInView(tw.currentNode)) { + break; + } + } + } + if (tw.currentNode == tw.root) { + tw.nextNode(); + } + + return this._speakInner(); + }); + }, + + stop() { + this._stopped = true; + this._win.speechSynthesis.cancel(); + }, + + skipNext() { + this._win.speechSynthesis.cancel(); + }, + + skipPrevious() { + this._goBackParagraphs(this._timeIntoParagraph < PREV_THRESHOLD ? 2 : 1); + }, + + setRate(rate) { + this._speechOptions.rate = rate; + /* repeat current paragraph */ + this._goBackParagraphs(1); + }, + + setVoice(voice) { + this._speechOptions.voice = this._getVoice(voice); + /* repeat current paragraph */ + this._goBackParagraphs(1); + }, + + _goBackParagraphs(count) { + let tw = this._treeWalker; + for (let i = 0; i < count; i++) { + if (!tw.previousNode()) { + tw.currentNode = tw.root; + } + } + this._win.speechSynthesis.cancel(); + } +}; + +/** + * The Highlighter class is used to highlight a range of text in a container. + * + * @param {nsIDOMElement} container a text container + */ +function Highlighter(container) { + this.container = container; +} + +Highlighter.prototype = { + /** + * Highlight the range within offsets relative to the container. + * + * @param {Number} startOffset the start offset + * @param {Number} length the length in characters of the range + */ + highlight(startOffset, length) { + let containerRect = this.container.getBoundingClientRect(); + let range = this._getRange(startOffset, startOffset + length); + let rangeRects = range.getClientRects(); + let win = this.container.ownerGlobal; + let computedStyle = win.getComputedStyle(range.endContainer.parentNode); + let nodes = this._getFreshHighlightNodes(rangeRects.length); + + let textStyle = {}; + for (let textStyleRule of kTextStylesRules) { + textStyle[textStyleRule] = computedStyle[textStyleRule]; + } + + for (let i = 0; i < rangeRects.length; i++) { + let r = rangeRects[i]; + let node = nodes[i]; + + let style = Object.assign({ + "top": `${r.top - containerRect.top + r.height / 2}px`, + "left": `${r.left - containerRect.left + r.width / 2}px`, + "width": `${r.width}px`, + "height": `${r.height}px` + }, textStyle); + + // Enables us to vary the CSS transition on a line change. + node.classList.toggle("newline", style.top != node.dataset.top); + node.dataset.top = style.top; + + // Enables CSS animations. + node.classList.remove("animate"); + win.requestAnimationFrame(() => { + node.classList.add("animate"); + }); + + // Enables alternative word display with a CSS pseudo-element. + node.dataset.word = range.toString(); + + // Apply style + node.style = Object.entries(style).map( + s => `${s[0]}: ${s[1]};`).join(" "); + } + }, + + /** + * Releases reference to container and removes all highlight nodes. + */ + remove() { + for (let node of this._nodes) { + node.remove(); + } + + this.container = null; + }, + + /** + * Returns specified amount of highlight nodes. Creates new ones if necessary + * and purges any additional nodes that are not needed. + * + * @param {Number} count number of nodes needed + */ + _getFreshHighlightNodes(count) { + let doc = this.container.ownerDocument; + let nodes = Array.from(this._nodes); + + // Remove nodes we don't need anymore (nodes.length - count > 0). + for (let toRemove = 0; toRemove < nodes.length - count; toRemove++) { + nodes.shift().remove(); + } + + // Add additional nodes if we need them (count - nodes.length > 0). + for (let toAdd = 0; toAdd < count - nodes.length; toAdd++) { + let node = doc.createElement("div"); + node.className = "narrate-word-highlight"; + this.container.appendChild(node); + nodes.push(node); + } + + return nodes; + }, + + /** + * Create and return a range object with the start and end offsets relative + * to the container node. + * + * @param {Number} startOffset the start offset + * @param {Number} endOffset the end offset + */ + _getRange(startOffset, endOffset) { + let doc = this.container.ownerDocument; + let i = 0; + let treeWalker = doc.createTreeWalker( + this.container, doc.defaultView.NodeFilter.SHOW_TEXT); + let node = treeWalker.nextNode(); + + function _findNodeAndOffset(offset) { + do { + let length = node.data.length; + if (offset >= i && offset <= i + length) { + return [node, offset - i]; + } + i += length; + } while ((node = treeWalker.nextNode())); + + // Offset is out of bounds, return last offset of last node. + node = treeWalker.lastChild(); + return [node, node.data.length]; + } + + let range = doc.createRange(); + range.setStart(..._findNodeAndOffset(startOffset)); + range.setEnd(..._findNodeAndOffset(endOffset)); + + return range; + }, + + /* + * Get all existing highlight nodes for container. + */ + get _nodes() { + return this.container.querySelectorAll(".narrate-word-highlight"); + } +}; diff --git a/components/narrate/VoiceSelect.jsm b/components/narrate/VoiceSelect.jsm new file mode 100644 index 000000000..861a21c97 --- /dev/null +++ b/components/narrate/VoiceSelect.jsm @@ -0,0 +1,300 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cu = Components.utils; + +this.EXPORTED_SYMBOLS = ["VoiceSelect"]; + +function VoiceSelect(win, label) { + this._winRef = Cu.getWeakReference(win); + + let element = win.document.createElement("div"); + element.classList.add("voiceselect"); + // eslint-disable-next-line no-unsanitized/property + element.innerHTML = + `<button class="select-toggle" aria-controls="voice-options"> + <span class="label">${label}</span> <span class="current-voice"></span> + </button> + <div class="options" id="voice-options" role="listbox"></div>`; + + this._elementRef = Cu.getWeakReference(element); + + let button = this.selectToggle; + button.addEventListener("click", this); + button.addEventListener("keypress", this); + + let listbox = this.listbox; + listbox.addEventListener("click", this); + listbox.addEventListener("mousemove", this); + listbox.addEventListener("keypress", this); + listbox.addEventListener("wheel", this, true); + + win.addEventListener("resize", () => { + this._updateDropdownHeight(); + }); +} + +VoiceSelect.prototype = { + add(label, value) { + let option = this._doc.createElement("button"); + option.dataset.value = value; + option.classList.add("option"); + option.tabIndex = "-1"; + option.setAttribute("role", "option"); + option.textContent = label; + this.listbox.appendChild(option); + return option; + }, + + addOptions(options) { + let selected = null; + for (let option of options) { + if (option.selected) { + selected = this.add(option.label, option.value); + } else { + this.add(option.label, option.value); + } + } + + this._select(selected || this.options[0], true); + }, + + clear() { + this.listbox.innerHTML = ""; + }, + + toggleList(force, focus = true) { + if (this.element.classList.toggle("open", force)) { + if (focus) { + (this.selected || this.options[0]).focus(); + } + + this._updateDropdownHeight(true); + this.listbox.setAttribute("aria-expanded", true); + this._win.addEventListener("focus", this, true); + } else { + if (focus) { + this.element.querySelector(".select-toggle").focus(); + } + + this.listbox.setAttribute("aria-expanded", false); + this._win.removeEventListener("focus", this, true); + } + }, + + handleEvent(evt) { + let target = evt.target; + + switch (evt.type) { + case "click": + if (target.classList.contains("option")) { + if (!target.classList.contains("selected")) { + this.selected = target; + } + + this.toggleList(false); + } else if (target.classList.contains("select-toggle")) { + this.toggleList(); + } + break; + + case "mousemove": + this.listbox.classList.add("hovering"); + break; + + case "keypress": + if (target.classList.contains("select-toggle")) { + if (evt.altKey) { + this.toggleList(true); + } else { + this._keyPressedButton(evt); + } + } else { + this.listbox.classList.remove("hovering"); + this._keyPressedInBox(evt); + } + break; + + case "wheel": + // Don't let wheel events bubble to document. It will scroll the page + // and close the entire narrate dialog. + evt.stopPropagation(); + break; + + case "focus": + if (!target.closest(".voiceselect")) { + this.toggleList(false, false); + } + break; + } + }, + + _getPagedOption(option, up) { + let height = elem => elem.getBoundingClientRect().height; + let listboxHeight = height(this.listbox); + + let next = option; + for (let delta = 0; delta < listboxHeight; delta += height(next)) { + let sibling = up ? next.previousElementSibling : next.nextElementSibling; + if (!sibling) { + break; + } + + next = sibling; + } + + return next; + }, + + _keyPressedButton(evt) { + if (evt.altKey && (evt.key === "ArrowUp" || evt.key === "ArrowUp")) { + this.toggleList(true); + return; + } + + let toSelect; + switch (evt.key) { + case "PageUp": + case "ArrowUp": + toSelect = this.selected.previousElementSibling; + break; + case "PageDown": + case "ArrowDown": + toSelect = this.selected.nextElementSibling; + break; + case "Home": + toSelect = this.selected.parentNode.firstElementChild; + break; + case "End": + toSelect = this.selected.parentNode.lastElementChild; + break; + } + + if (toSelect && toSelect.classList.contains("option")) { + evt.preventDefault(); + this.selected = toSelect; + } + }, + + _keyPressedInBox(evt) { + let toFocus; + let cur = this._doc.activeElement; + + switch (evt.key) { + case "ArrowUp": + toFocus = cur.previousElementSibling || this.listbox.lastElementChild; + break; + case "ArrowDown": + toFocus = cur.nextElementSibling || this.listbox.firstElementChild; + break; + case "PageUp": + toFocus = this._getPagedOption(cur, true); + break; + case "PageDown": + toFocus = this._getPagedOption(cur, false); + break; + case "Home": + toFocus = cur.parentNode.firstElementChild; + break; + case "End": + toFocus = cur.parentNode.lastElementChild; + break; + case "Escape": + this.toggleList(false); + break; + } + + if (toFocus && toFocus.classList.contains("option")) { + evt.preventDefault(); + toFocus.focus(); + } + }, + + _select(option, suppressEvent = false) { + let oldSelected = this.selected; + if (oldSelected) { + oldSelected.removeAttribute("aria-selected"); + oldSelected.classList.remove("selected"); + } + + if (option) { + option.setAttribute("aria-selected", true); + option.classList.add("selected"); + this.element.querySelector(".current-voice").textContent = + option.textContent; + } + + if (!suppressEvent) { + let evt = this.element.ownerDocument.createEvent("Event"); + evt.initEvent("change", true, true); + this.element.dispatchEvent(evt); + } + }, + + _updateDropdownHeight(now) { + let updateInner = () => { + let winHeight = this._win.innerHeight; + let listbox = this.listbox; + let listboxTop = listbox.getBoundingClientRect().top; + listbox.style.maxHeight = (winHeight - listboxTop - 10) + "px"; + }; + + if (now) { + updateInner(); + } else if (!this._pendingDropdownUpdate) { + this._pendingDropdownUpdate = true; + this._win.requestAnimationFrame(() => { + updateInner(); + delete this._pendingDropdownUpdate; + }); + } + }, + + _getOptionFromValue(value) { + return Array.from(this.options).find(o => o.dataset.value === value); + }, + + get element() { + return this._elementRef.get(); + }, + + get listbox() { + return this._elementRef.get().querySelector(".options"); + }, + + get selectToggle() { + return this._elementRef.get().querySelector(".select-toggle"); + }, + + get _win() { + return this._winRef.get(); + }, + + get _doc() { + return this._win.document; + }, + + set selected(option) { + this._select(option); + }, + + get selected() { + return this.element.querySelector(".options > .option.selected"); + }, + + get options() { + return this.element.querySelectorAll(".options > .option"); + }, + + set value(value) { + this._select(this._getOptionFromValue(value)); + }, + + get value() { + let selected = this.selected; + return selected ? selected.dataset.value : ""; + } +}; diff --git a/components/narrate/moz.build b/components/narrate/moz.build new file mode 100644 index 000000000..fe1dfb6ca --- /dev/null +++ b/components/narrate/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES.narrate = [ + 'NarrateControls.jsm', + 'Narrator.jsm', + 'VoiceSelect.jsm' +] |