content_script.js

/* eslint-env browser, webextensions */

/**
 * Extension settings custom schema
 * @typedef {Object} ExtensionSettings
 * @property {object} style defines style values
 * @property {number} style.font-size
 * @property {number} style.line-height
 * @property {number} style.letter-spacing
 * @property {number} style.word-spacing
 * @property {object} styleUnits defines style units
 * @property {string} styleUnits.font-size
 * @property {string} styleUnits.line-height
 * @property {string} styleUnits.letter-spacing
 * @property {string} styleUnits.word-spacing
 * @property {number} linesPerParagraph lines per paragraph
 * @property {number} wordsPerLine words per line
 * @property {boolean} autoScan auto scan page for p tags
 * @property {boolean} styleEnable enables style modifications
 * @property {boolean} DOMEnable enables dom modifications
 */

/**
 * Extension settings custom schema
 * @typedef {array} MutationList
 * @property {MutationRecord} MutationRecord defines style values
 */


const OptionsSchema = {
	schema: {
		style: {
			'font-size': 16,
			'line-height': 0,
			'letter-spacing': 0,
			'word-spacing': 0
		},
		styleUnits: {
			'font-size': 'px',
			'line-height': '',
			'letter-spacing': 'px',
			'word-spacing': 'px'
		},

		linesPerParagraph: 3,
		wordsPerLine: 8,

		autoScan: true,
		styleEnable: true,
		DOMEnable: true,
		enabled: true
	},

	/**
	 * Enforces a schema for the extension's settings
	 * @param {ExtensionSettings} settings Settings object
	 */
	verifySchema: function ({ style, styleUnits, ...pluginSettings }){
		const { style: schemaStyle, styleUnits: schemaStyleUnits, ...schemaSettings } = this.schema

		try{
			if(
				Object.keys(style).length !== Object.keys(schemaStyle).length
				|| Object.keys(styleUnits).length !== Object.keys(schemaStyleUnits).length
				|| Object.keys(pluginSettings).length !== Object.keys(schemaSettings).length
			) return false

			return true
		}
		catch(err){
			return false
		}
	}
}

/**
 * @property {boolean} observationEnabled
 * @property {ExtensionSettings} settings
 * @property {ExtensionSettings} settings
 */
class DOMManipulator{
	/**
	 * @param {ExtensionSettings} settings
	 */
	constructor (settings){
		this.state = {
			trackedElements: []
		}

		if(!OptionsSchema.verifySchema(settings))
			throw Error ('Settings passed to DOMManipulator does not match schema')

		this.settings = { ...settings }
		this.observationEnabled = false
		this.currentURL = null

		this.toggleObservation = this.toggleObservation.bind(this)

		parseAndAttachCSS(this.settings)
	}

	/**
	 *
	 * @param {DOMTree} newDOM
	 * @param {DOMTree} oldDOM
	 */
	shouldUpdate (newState){
		if(JSON.stringify(newState) === JSON.stringify(this.state)) return false
		return true
	}


	/**
	 * Formats paragraphs according to settings
	 * @param {HTMLElement} node Text content of paragraphs
	 */
	formatNode (htmlNode){
		const { wordsPerLine, linesPerParagraph } = this.settings

		htmlNode.childNodes.forEach( node => {
			// if(node.nodeType !== 3) return this.formatNode(node)
			if(node.nodeType === 3){
				const textContent = node.textContent || ''

				let wordCounter = 0
				let lineCounter = 1

				node.textContent = textContent.split(/\s/g).reduce((final, word) => {
					if(wordCounter++ < wordsPerLine) return final + word + ' '
					else {
						wordCounter = 1

						if(lineCounter++ < linesPerParagraph) return final + '\r\n' + word + ' '

						else {
							lineCounter = 1
							return final + '\r\n \r\n' + word + ' '
						}

					}
				}, '')

				htmlNode.setAttribute('style', 'white-space: pre-line !important;')
			}
		})

	}

	/**
	 * Uses the list of currently tracked <p> tags and formats them according to settings
	 */
	reformatDOM (){
		this.state.trackedElements.forEach(node => {
			if(this.settings.DOMEnable) this.formatNode(node)
			if(this.settings.styleEnable) node.classList.add('ally-reads_improved_reading')
		})

		setTimeout(this.toggleObservation, 1000)
	}

	//BUG: the observer catches modifications made by the extension itself.
	/**
	 * Observation handler
	 * @param {array} mutationList - Array of MutationRecord's
	 */

	observe (mutationList){
		if(this.settings.enabled){
			if(this.observationEnabled || window.location.href !== this.currentURL){
				if(this.observationEnabled) this.toggleObservation()

				setTimeout(() => {
					for(const mutation of mutationList){
						//Updating the tracking list will get the latest result regardless of current index in the iteration loop.

						if(mutation.type === 'childList') {

							this.setState({
								...this.state,
								trackedElements: this.getNewTrackingList()
							})

							this.reformatDOM()

							break
						}
					}
				}, 2000) // scans the dom after 2 second of detecting mutation. This is to avoid wasted scans.
			}
			//REFACTORME: Use a better detection model


		}
	}

	/**
	 * Gets the new tracking list based on whether it's the same page
	 * @returns {array} array of HTMLElement
	 */

	getNewTrackingList (){
		if(window.location.href === this.currentURL){

			return Array
				.from(document.querySelectorAll('p'))
				.slice(this.state.trackedElements.length)
		}
		else {
			this.currentURL = window.location.href
			return Array.from(document.querySelectorAll('p'))
		}
	}

	/**
	 * Toggles the observation handler
	 */
	toggleObservation (){
		this.observationEnabled = !this.observationEnabled
	}

	/**
	 * Scans the DOM for <p> tags and listens for changes to the dom. Uses MutationObserver to check for DOM mutations and an interval for older browsers.
	 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver|Mutation Observer - MDN}
	 */
	scanDOM (){
		const newTrackingList = this.getNewTrackingList()
		this.setState({ ...this.state, trackedElements: newTrackingList })
		this.reformatDOM()

		if(window.MutationObserver){
			// calling using observe.call instead of direct to avoid setting MutationObserver as `this` for the handler.
			const observer = new MutationObserver(mutationList => this.observe.call(this, mutationList))
			observer.observe(document.querySelector('body'), { subtree: true, childList: true })

		}
		else {
			setInterval(() => {

				this.setState({
					...this.state,
					trackedElements: this.getNewTrackingList()
				})
				this.reformatDOM()

			}, 5000)
		}

	}

	/**
	 * Defers and updates state
	 * @param {array} newTrackingList A new list of <p> tags
	 */
	setState (nextState){
		if(this.shouldUpdate(nextState)){
			this.state = { ...nextState }
		}
	}
}

/**
 * Starts the extension content_script after a timeout. The timeout here is used to avoid expensive wasted scans for browsers that support MutationObserver.
 * @see {@link scanDOM}
 */
function start (){

	setTimeout(async () => {
		const options = await browser.storage.local.get()

		if(options.enabled){
			const mod = new DOMManipulator(options)

			if(options.autoScan) mod.scanDOM()

			window.addEventListener('keypress', () => {
				mod.settings.enabled = false

				setTimeout(() => {
					mod.settings.enabled = true
				}, 5000)
			})
		}


	}, 3000)
}


/**
 * Parses extension settings and creates a new stylesheet before attaching it to the DOM
 * @param {ExtensionSettings} settings
 */
function parseAndAttachCSS (settings){
	const { style, styleUnits } = settings

	const newStylesheet = document.createElement('style')
	newStylesheet.type = 'text/css'

	newStylesheet.innerHTML = '.ally-reads_improved_reading, .ally-reads_improved_reading *{\n'

	for(const key in style){
		if(style[key] > 0) newStylesheet.innerHTML += `${key}: ${style[key]}${styleUnits[key]} !important;\n`
	}

	newStylesheet.innerHTML += '}'

	document.getElementsByTagName('head')[0].appendChild(newStylesheet)
}


window.addEventListener('load', () => {
	start()
})

//TESTING: uncomment this line when testing
// module.exports = { DOMManipulator, OptionsSchema, parseAndAttachCSS }