import { LitElement, html, css } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { styleMap } from 'lit-html/directives/style-map.js'; import { useStore, ProviderStore, dispatchUpdate } from './state/store'; import { hasDirectText } from './utils/utils'; /** * The Ol Speed Reader element * * Allows highlighting of the words on the page to pace * reading, */ // TODO: // Implement Range to fix line breaks
tags // Select where to start within element // Fix button location // Button changes color with selected color because it is a span // Add line highlighting on the line that the word is highlighted @customElement('ol-reader') export class OLReader extends LitElement { // Reactive property to see if the reader component is active @state() reader: boolean = useStore(this).reader; // Variable to get the active element @state() activeElement: ProviderStore['activeElement'] | null = useStore(this).activeElement; @state() previousElement: HTMLElement | null = null; // Gets the user highlight color from the store @state() highlightColor: string = useStore(this).highlightColor; @state() index = 0; @state() readerSpeed: number = useStore(this).readerSpeed; @state() speed = 100; nIntervId: NodeJS.Timer | null = null; timer() { // check if already an interval has been set up if (!this.nIntervId) { this.nIntervId = setInterval(() => this.index++, this.speed); } else { this.clearTimer(); } } clearTimer() { if (this.nIntervId) clearInterval(this.nIntervId); // release our intervalID from the variable this.nIntervId = null; } // Returns the highlight color provided by the store getBackgroundColor(): string { return this.highlightColor; } static override styles = css` #ol-reader { position: absolute; top: 0; left: 0; z-index: 1000; background-color: yellow; opacity: .25; pointer-events: none; } #reader-button { position: absolute; display: flex; align-items: center; justify-content: center; border-radius: 50%; outline: none; border: 0; background: blue; box-shadow: 0 0 3px 1px rgba(0,0,0,.2); aspect-ratio: 1; color: white; cursor: pointer; transition: ease .3s; } .material-icons { font-family: 'Material Icons'; font-weight: normal; font-style: normal; font-size: 20px; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-block; white-space: nowrap; word-wrap: normal; direction: ltr; font-feature-settings: 'liga'; -webkit-font-feature-settings: 'liga'; -webkit-font-smoothing: antialiased; } ` override connectedCallback(): void { super.connectedCallback(); window.onresize = () => { this.requestUpdate(); } } // Gets the styling associated with the button getButtonStyles() { if (!this.activeElement) return {}; const { left } = this.activeElement.getBoundingClientRect(); const { offsetTop } = this.activeElement; const dLeft = left - 30 < 0; const dTop = offsetTop - 30 < 0; return ({ top: (dLeft && !dTop ? offsetTop - 30 : offsetTop) + 'px', left: (dLeft ? 0 : left - 30) + 'px', backgroundColor: this.getBackgroundColor(), outlineColor: 'black' }) } override updated(changedProperties: Map) { if (!this.reader) return; const oldValue: Element = changedProperties.get('activeElement'); // This should use some other serialization comparison const s1 = oldValue?.textContent?.replace(/[^a-zA-Z]/g, '') || ''; const s2 = this.activeElement?.textContent?.replace(/[^a-zA-Z]/g, '') || ''; if (oldValue && s1 != s2) { // Reset the reader this.clearTimer(); this.index = 0; const marks = oldValue?.getElementsByClassName("ol-speedreader-highlight"); for (const mark of marks) { // Unwrap the highlight const parent = mark.parentNode; const text = document.createTextNode(mark.textContent || ''); parent?.insertBefore(text, mark); mark.remove(); } } } override render() { //if active element does not exist or there's no text //inside active element, then do not display highlighter if (!this.activeElement || !this.reader) return html``; if (!hasDirectText(this.activeElement)) return html``; const { activeElement } = this; const element: Node = activeElement.cloneNode(false); const content = activeElement.textContent ? activeElement.textContent.split(' ').filter(c => c) : []; //Return nothing if there is no text inside activeElement excluding children if (!content.length) return html``; // Create a mark to highlight the word and set its color to the highlighter color const word = document.createElement('span'); word.style.backgroundColor = this.getBackgroundColor(); //this will revert the background color to whatever it was if (!this.reader) word.style.backgroundColor = activeElement.style.backgroundColor; // word.style.color = 'black'; if (content.length < this.index) { this.clearTimer(); this.index = 0; } const text = document.createTextNode(" " + content.slice(this.index, this.index + 1).join(" ") + " "); //updates speed with each word if (this.index != 0 && this.nIntervId) { // the average meaningful word length is 5 // formula is 60000 milliseconds per min / wpm / avg word length * length of the word this.speed = (60000 / this.readerSpeed / 5) * text.length; this.clearTimer(); this.timer(); } word.appendChild(text); word.setAttribute("class", "ol-speedreader-highlight"); word.setAttribute("data-index", String(this.index)); //creates the parts before and after the selected highlighted word const pre = document.createTextNode(content.slice(0, this.index).join(" ") || ''); const post = document.createTextNode(" " + content.slice(this.index + 1).join(" ") || ''); element.appendChild(pre); element.appendChild(word); element.appendChild(post); activeElement.parentElement?.replaceChild(element, activeElement); dispatchUpdate("activeElement", element as HTMLElement); this.previousElement = this.activeElement; //clears the timer when the reader is disabled if (!this.reader) { this.clearTimer(); return html``; } return ( html`
` ) } } declare global { interface HTMLElementTagNameMap { 'ol-reader': OLReader; } }