Firefox's Reader mode highlights words when using text-to-speech. Here is related code:
const h = new Highlighter(window, document.getElementById('your-el'));
// highlight(startOffset, length)
h.highlight(8, 12);Firefox's Reader mode highlights words when using text-to-speech. Here is related code:
const h = new Highlighter(window, document.getElementById('your-el'));
// highlight(startOffset, length)
h.highlight(8, 12);| .narrate-word-highlight { | |
| display: inline-block; | |
| position: absolute; | |
| display: none; | |
| transform: translate(-50%, calc(-50% + 4px)); | |
| z-index: -1; | |
| border-bottom-style: solid; | |
| border-bottom-width: 7px; | |
| transition: left 0.1s ease, width 0.1s ease; | |
| border-bottom-color: #6f6f6f; | |
| } | |
| .narrate-word-highlight.newline { | |
| transition: none; | |
| } |
| /* 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/. */ | |
| // Original source: | |
| // https://github.com/mozilla/gecko-dev/blob/d36cf98aa85f24ceefd07521b3d16b9edd2abcb7/toolkit/components/narrate/Narrator.jsm | |
| // https://github.com/mozilla/gecko-dev/blob/d36cf98aa85f24ceefd07521b3d16b9edd2abcb7/toolkit/themes/shared/narrate.css | |
| /** | |
| * The Highlighter class is used to highlight a range of text in a container. | |
| * | |
| * @param {Element} container a text container | |
| */ | |
| export function Highlighter(win, container) { | |
| this.win = win; | |
| this.container = container; | |
| } | |
| // 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", | |
| ]; | |
| 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 computedStyle = this.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"); | |
| this.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"); | |
| }, | |
| }; |