Skip to content

Instantly share code, notes, and snippets.

@python273
Created May 12, 2021 18:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save python273/2ee2e709baf8a14ec3c96ea2e22ab054 to your computer and use it in GitHub Desktop.
Save python273/2ee2e709baf8a14ec3c96ea2e22ab054 to your computer and use it in GitHub Desktop.
JavaScript: how to find screen coordinates of a substring in an element OR how to hightlight words

JavaScript: how to find screen coordinates of a substring in an element OR how to hightlight words

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);

https://github.com/mozilla/gecko-dev/blob/d36cf98aa85f24ceefd07521b3d16b9edd2abcb7/toolkit/components/narrate/Narrator.jsm#L323-L466

styles: https://github.com/mozilla/gecko-dev/blob/d36cf98aa85f24ceefd07521b3d16b9edd2abcb7/toolkit/themes/shared/narrate.css

.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");
},
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment