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