The PR fixes eslint issues in *.js files and removes the related rules from the backlist.
315 lines
11 KiB
JavaScript
315 lines
11 KiB
JavaScript
/* Copyright (c) 2023 The Brave Authors. All rights reserved.
|
|
* 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 https://mozilla.org/MPL/2.0/. */
|
|
|
|
class speedreaderUtils {
|
|
// These IDs are hardcoded in extractor.rs
|
|
static showOriginalLinkId = 'c93e2206-2f31-4ddc-9828-2bb8e8ed940e'
|
|
static readTimeDivId = 'da24e4ef-db57-4b9f-9fa5-548924fc9c32'
|
|
static metaDataDivId = '3bafd2b4-a87d-4471-8134-7a9cca092000'
|
|
static contentDivId = '7c08a417-bf02-4241-a55e-ad5b8dc88f69'
|
|
|
|
static defaultSpeedreaderData = {
|
|
showOriginalLinkText: 'View original',
|
|
playButtonTitle: 'Play / Pause',
|
|
averageWordsPerMinute: 265,
|
|
minutesText: 'min. read',
|
|
ttsEnabled: false,
|
|
ttsReading: false,
|
|
}
|
|
|
|
static $ = (id) => {
|
|
return document.getElementById(id)
|
|
}
|
|
|
|
static adoptStyles = () => {
|
|
for (const style of document.styleSheets) {
|
|
style.disabled = true
|
|
}
|
|
|
|
const braveStyles =
|
|
document.querySelectorAll('script[type="brave-style-data"]')
|
|
for (const styleData of braveStyles) {
|
|
const style = new CSSStyleSheet()
|
|
style.replaceSync(styleData.innerText)
|
|
document.adoptedStyleSheets.push(style)
|
|
}
|
|
document.body.hidden = false
|
|
}
|
|
|
|
static initShowOriginalLink = () => {
|
|
const link = this.$(this.showOriginalLinkId)
|
|
if (!link)
|
|
return
|
|
|
|
link.innerText = this.speedreaderData.showOriginalLinkText
|
|
link.addEventListener('click', (e) => {
|
|
window.speedreader.showOriginalPage()
|
|
})
|
|
window.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape' &&
|
|
!event.altKey && !event.shiftKey &&
|
|
!event.metaKey && !event.ctrlKey) {
|
|
window.speedreader.showOriginalPage()
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}
|
|
})
|
|
}
|
|
|
|
static calculateReadtime = () => {
|
|
const readTimeDiv = this.$(this.readTimeDivId)
|
|
if (!readTimeDiv)
|
|
return
|
|
|
|
const text = document.body.innerText
|
|
const words = text.trim().split(/\s+/).length
|
|
const wpm = this.speedreaderData.averageWordsPerMinute
|
|
const minutes = Math.ceil(words / wpm)
|
|
|
|
readTimeDiv.innerText = minutes + ' ' + this.speedreaderData.minutesText
|
|
}
|
|
|
|
static getTextContent = (element) => {
|
|
if (!element) {
|
|
return null
|
|
}
|
|
const text = (element.innerText || element.wholeText)?.replace(/\n|\r +/g, ' ')?.trim()
|
|
if (text?.length > 0) {
|
|
return text
|
|
}
|
|
return null
|
|
}
|
|
|
|
static isTtsEnabled() {
|
|
return !navigator.userAgentData.mobile || this.speedreaderData.ttsEnabled
|
|
}
|
|
|
|
static initTextToSpeak = () => {
|
|
if (!this.isTtsEnabled()) {
|
|
return
|
|
}
|
|
|
|
{
|
|
const spans =
|
|
this.$(this.contentDivId)?.querySelectorAll('p > span')
|
|
|
|
for (const span of spans) {
|
|
const p = span.parentNode
|
|
|
|
while (span.childNodes.length > 0) {
|
|
p.insertBefore(span.firstChild, span)
|
|
}
|
|
p.removeChild(span)
|
|
}
|
|
}
|
|
|
|
let textToSpeak = 0
|
|
|
|
const makeParagraph = (elem) => {
|
|
if (!elem) {
|
|
return false
|
|
}
|
|
const text = this.getTextContent(elem)
|
|
if (text) {
|
|
elem.setAttribute('tts-paragraph-index', textToSpeak++)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
const createPlayer = (p) => {
|
|
const player = document.createElement('span')
|
|
player.classList.add('tts-paragraph-player')
|
|
const button = document.createElement('span')
|
|
button.classList.add('tts-circle')
|
|
const playButton = document.createElement('span')
|
|
playButton.classList.add('tts-paragraph-player-button', 'tts-play-icon')
|
|
playButton.title = this.speedreaderData.playButtonTitle
|
|
playButton.onclick = button.onclick = (ev) => {
|
|
window.speedreader.ttsPlayPause(parseInt(p.getAttribute('tts-paragraph-index')))
|
|
}
|
|
player.insertAdjacentElement('afterbegin', playButton)
|
|
player.insertAdjacentElement('afterbegin', button)
|
|
p.insertAdjacentElement('afterbegin', player)
|
|
}
|
|
|
|
makeParagraph(this.$(this.metaDataDivId)?.querySelector('.title'))
|
|
makeParagraph(this.$(this.metaDataDivId)?.querySelector('.subhead'))
|
|
|
|
// Returns true if node is a leaf of the parentNode.
|
|
const isChildOf = (node, parentNode) => {
|
|
if (!parentNode) return false
|
|
|
|
while (node) {
|
|
if (node == parentNode) return true
|
|
node = node.parentNode
|
|
}
|
|
return false
|
|
}
|
|
|
|
const nodes = document.createNodeIterator(
|
|
this.$(this.contentDivId),
|
|
NodeFilter.SHOW_TEXT,
|
|
{ acceptNode(n) { return NodeFilter.FILTER_ACCEPT } })
|
|
|
|
let currentParent = null
|
|
let paragraphs = []
|
|
let currentNode = null;
|
|
// eslint-disable-next-line no-cond-assign
|
|
while (currentNode = nodes.nextNode()) {
|
|
if (currentNode === nodes.root ||
|
|
isChildOf(currentNode, currentParent) ||
|
|
!this.getTextContent(currentNode)) {
|
|
continue
|
|
}
|
|
currentParent = currentNode.parentNode
|
|
|
|
// Remove all nodes that are children of the current node.
|
|
// It is only possible when a paragraph starts with <tag>text</tag>,
|
|
// in this case NodeIterator produces sequence <tag> -> <p>,
|
|
// but <p> is a parent of the <tag>, so we add <p> and remove <tag>
|
|
paragraphs = paragraphs.filter((p) => {
|
|
return !isChildOf(p, currentParent)
|
|
})
|
|
paragraphs.push(currentNode.parentElement)
|
|
}
|
|
|
|
paragraphs.forEach((p) => {
|
|
if (makeParagraph(p)) {
|
|
createPlayer(p)
|
|
}
|
|
})
|
|
|
|
const underline = document.createElement('div')
|
|
underline.classList.add('tts-underline')
|
|
document.body.insertAdjacentElement('beforeend', underline)
|
|
}
|
|
|
|
static extractTextToSpeak = () => {
|
|
const paragraphs = Array.from(document.querySelectorAll('[tts-paragraph-index]'))
|
|
.map((p) => {
|
|
return p.textContent
|
|
})
|
|
|
|
return {
|
|
title: document.title,
|
|
author: this.$(this.metaDataDivId)?.querySelector('.author')?.textContent,
|
|
desciption: this.$(this.metaDataDivId)?.querySelector('.subhead')?.textContent,
|
|
paragraphs: paragraphs
|
|
}
|
|
}
|
|
|
|
static highlightWord = (paragraph, start, end) => {
|
|
if (!paragraph || start >= end) {
|
|
CSS.highlights.clear()
|
|
const underline = document.querySelector('.tts-underline')
|
|
underline.classList.remove('tts-underline-visible')
|
|
underline.setAttribute('data-top', 0)
|
|
return
|
|
}
|
|
|
|
const nodes = document.createNodeIterator(
|
|
paragraph,
|
|
NodeFilter.SHOW_TEXT,
|
|
{ acceptNode(n) { return NodeFilter.FILTER_ACCEPT } })
|
|
|
|
const range = new Range()
|
|
|
|
let startNode = null
|
|
let endNode = null
|
|
let node = null
|
|
// eslint-disable-next-line no-cond-assign
|
|
while (node = nodes.nextNode()) {
|
|
if (!startNode) {
|
|
if (start < node.textContent.length) {
|
|
startNode = node
|
|
range.setStart(node, start)
|
|
}
|
|
start -= node.textContent.length
|
|
}
|
|
if (!endNode) {
|
|
if (end <= node.textContent.length) {
|
|
endNode = node
|
|
range.setEnd(endNode, end)
|
|
}
|
|
end -= node.textContent.length
|
|
}
|
|
}
|
|
|
|
const underline = document.querySelector('.tts-underline')
|
|
const bodyRect = document.body.getBoundingClientRect()
|
|
const highlightedRect = range.getBoundingClientRect()
|
|
|
|
const top = highlightedRect.bottom - bodyRect.top
|
|
const left = highlightedRect.left - bodyRect.left
|
|
const width = highlightedRect.width + 2;
|
|
|
|
underline.classList.toggle('tts-underline-newline',
|
|
underline.getAttribute('data-top') != top)
|
|
underline.classList.toggle('tts-underline-decrease',
|
|
underline.getAttribute('data-width') < width)
|
|
underline.classList.add('tts-underline-visible')
|
|
underline.setAttribute('data-top', top)
|
|
underline.setAttribute('data-width', width)
|
|
|
|
underline.style.setProperty('--tts-underline-top', top + 'px');
|
|
underline.style.setProperty('--tts-underline-left', left + 'px');
|
|
underline.style.setProperty('--tts-underline-width', width + 'px');
|
|
|
|
CSS.highlights.set('tts-highlighted-word', new Highlight(range))
|
|
}
|
|
|
|
static setTtsButtonIcon = (e, i) => {
|
|
const button = e?.querySelector('.tts-paragraph-player-button')
|
|
if (button) {
|
|
button.classList.remove('tts-play-icon', 'tts-pause-icon')
|
|
button.classList.add(i)
|
|
}
|
|
}
|
|
|
|
static highlightText = (ttsParagraphIndex, charIndex, length) => {
|
|
if (!this.isTtsEnabled()) {
|
|
return
|
|
}
|
|
|
|
document.querySelectorAll('.tts-highlighted').forEach((e) => {
|
|
if (e.getAttribute('tts-paragraph-index') != ttsParagraphIndex) {
|
|
e.classList.remove('tts-highlighted')
|
|
this.setTtsButtonIcon(e, 'tts-play-icon')
|
|
}
|
|
})
|
|
|
|
const paragraph = document.querySelector(
|
|
'[tts-paragraph-index="' + ttsParagraphIndex + '"]')
|
|
if (paragraph) {
|
|
paragraph.classList.add('tts-highlighted')
|
|
if (this.speedreaderData.ttsReading) {
|
|
this.setTtsButtonIcon(paragraph, 'tts-pause-icon')
|
|
} else {
|
|
this.setTtsButtonIcon(paragraph, 'tts-play-icon')
|
|
}
|
|
}
|
|
this.highlightWord(paragraph, charIndex, charIndex + length)
|
|
}
|
|
|
|
static setTtsReadingState = (reading) => {
|
|
this.speedreaderData.ttsReading = reading
|
|
if (!this.speedreaderData.ttsReading) {
|
|
this.setTtsButtonIcon(document.querySelector('.tts-highlighted'), 'tts-play-icon')
|
|
}
|
|
}
|
|
}
|
|
|
|
(() => {
|
|
speedreaderUtils.speedreaderData = Object.assign(
|
|
speedreaderUtils.defaultSpeedreaderData,
|
|
window.speedreaderData)
|
|
|
|
speedreaderUtils.adoptStyles()
|
|
speedreaderUtils.initShowOriginalLink()
|
|
speedreaderUtils.calculateReadtime()
|
|
speedreaderUtils.initTextToSpeak()
|
|
})()
|