UNPKG

rwt-reading-points

Version:

Percentage read, reading time & points, a standards-based DOM Component

298 lines (246 loc) 10.8 kB
//============================================================================= // // File: /node_modules/rwt-reading-points/rwt-reading-points.js // Language: ECMAScript 2015 // Copyright: Read Write Tools © 2020 // License: MIT // Initial date: Jan 14, 2020 // Purpose: Percentage read, reading time & points // //============================================================================= import ReadersData from './readers-data.class.js'; const Static = { componentName: 'rwt-reading-points', elementInstance: 1, htmlURL: '/node_modules/rwt-reading-points/rwt-reading-points.blue', cssURL: '/node_modules/rwt-reading-points/rwt-reading-points.css', htmlText: null, cssText: null }; Object.seal(Static); export default class RwtReadingPoints extends HTMLElement { constructor() { super(); // guardrails this.instance = Static.elementInstance++; this.isComponentLoaded = false; // initialization this.hasShadowDom = false; // external elements this.frame = null; // #frame : use scrolling events of this element to track reading time this.positioner = null; // #objectives : insert the fly-in panel immediately below this element // child elements this.panel = null; this.container = null; this.level = null; this.points = null; // data this.hasValidSetup = true; // true when data- attributes are valid and #objectives element found this.skillCategory = 'General'; // open ended value this.skillLevel = 'Simple'; // Simple, Moderate, Challenge this.skillPoints = 1; // 1,2,3,4,5, ... this.suggestedReadingTime = 60; // suggested reading time in seconds this.percentRead = 0; // determined with calculateReadingTime() // readingTime this.loadTime = Date.now(); this.firstScrollTime = null; this.lastScrollTime = null; Object.seal(this); } //------------------------------------------------------------------------- // customElement life cycle callbacks //------------------------------------------------------------------------- async connectedCallback() { if (!this.isConnected) return; // connectedCallback is called again when this customElement is re-inserted back into the document (see below) if (this.hasShadowDom == true) return; try { var htmlFragment = await this.getHtmlFragment(); var styleElement = await this.getCssStyleElement(); this.attachShadow({mode: 'open'}); this.shadowRoot.appendChild(htmlFragment); this.shadowRoot.appendChild(styleElement); this.hasShadowDom = true; this.identifyChildren(); this.registerEventListeners(); this.readAttributes(); this.initializeText(); this.validateSetup(); this.show(); this.sendComponentLoaded(); } catch (err) { console.log(err.message); } } //------------------------------------------------------------------------- // initialization //------------------------------------------------------------------------- // Only the first instance of this component fetches the HTML text from the server. // All other instances wait for it to issue an 'html-template-ready' event. // If this function is called when the first instance is still pending, // it must wait upon receipt of the 'html-template-ready' event. // If this function is called after the first instance has already fetched the HTML text, // it will immediately issue its own 'html-template-ready' event. // When the event is received, create an HTMLTemplateElement from the fetched HTML text, // and resolve the promise with a DocumentFragment. getHtmlFragment() { return new Promise(async (resolve, reject) => { var htmlTemplateReady = `${Static.componentName}-html-template-ready`; document.addEventListener(htmlTemplateReady, () => { var template = document.createElement('template'); template.innerHTML = Static.htmlText; resolve(template.content); }); if (this.instance == 1) { var response = await fetch(Static.htmlURL, {cache: "no-cache", referrerPolicy: 'no-referrer'}); if (response.status != 200 && response.status != 304) { reject(new Error(`Request for ${Static.htmlURL} returned with ${response.status}`)); return; } Static.htmlText = await response.text(); document.dispatchEvent(new Event(htmlTemplateReady)); } else if (Static.htmlText != null) { document.dispatchEvent(new Event(htmlTemplateReady)); } }); } // Use the same pattern to fetch the CSS text from the server // When the 'css-text-ready' event is received, create an HTMLStyleElement from the fetched CSS text, // and resolve the promise with that element. getCssStyleElement() { return new Promise(async (resolve, reject) => { var cssTextReady = `${Static.componentName}-css-text-ready`; document.addEventListener(cssTextReady, () => { var styleElement = document.createElement('style'); styleElement.innerHTML = Static.cssText; resolve(styleElement); }); if (this.instance == 1) { var response = await fetch(Static.cssURL, {cache: "no-cache", referrerPolicy: 'no-referrer'}); if (response.status != 200 && response.status != 304) { reject(new Error(`Request for ${Static.cssURL} returned with ${response.status}`)); return; } Static.cssText = await response.text(); document.dispatchEvent(new Event(cssTextReady)); } else if (Static.cssText != null) { document.dispatchEvent(new Event(cssTextReady)); } }); } //^ Identify this component's children identifyChildren() { this.frame = document.getElementById('frame'); this.positioner = document.getElementById('objectives'); this.panel = this.shadowRoot.getElementById('panel'); this.container = this.shadowRoot.getElementById('container'); this.level = this.shadowRoot.getElementById('level'); this.points = this.shadowRoot.getElementById('points'); } registerEventListeners() { // window events window.addEventListener('unload', this.onDocumentUnload.bind(this)); // document events this.frame.addEventListener('scroll', this.onScroll.bind(this)); } readAttributes() { if (this.hasAttribute('data-category')) this.skillCategory = this.getAttribute('data-category'); if (this.hasAttribute('data-level')) this.skillLevel = this.getAttribute('data-level'); if (this.hasAttribute('data-points')) this.skillPoints = parseInt(this.getAttribute('data-points')); if (this.hasAttribute('data-time')) this.suggestedReadingTime = parseInt(this.getAttribute('data-time')); } initializeText() { this.level.innerText = this.skillLevel; this.points.innerText = this.skillPoints; } validateSetup() { // if the data attributes are not properly set, do not display the panel if (isNaN(this.skillPoints) || this.skillPoints == 0) this.hasValidSetup = false; if (isNaN(this.suggestedReadingTime) || this.suggestedReadingTime == 0) this.hasValidSetup = false; // if the #frame or #objectives element were not found, do not display panel if (this.frame == null) this.hasValidSetup = false; if (this.positioner == null) this.hasValidSetup = false; } //^ Inform the document's custom element that it is ready for programmatic use sendComponentLoaded() { this.isComponentLoaded = true; this.dispatchEvent(new Event('component-loaded', {bubbles: true})); } //^ A Promise that resolves when the component is loaded waitOnLoading() { return new Promise((resolve) => { if (this.isComponentLoaded == true) resolve(); else this.addEventListener('component-loaded', resolve); }); } //------------------------------------------------------------------------- // window events //------------------------------------------------------------------------- onDocumentUnload() { if (!this.hasValidSetup) return; var readersData = new ReadersData(); var rc = readersData.readFromStorage(); var title = document.querySelector('title').innerText; var cappedReadingTime = this.calculateReadingTime(); readersData.addPage(window.location.pathname, title, this.skillCategory, this.skillLevel, this.skillPoints, this.suggestedReadingTime, this.percentRead, cappedReadingTime); readersData.writeToStorage(); } //------------------------------------------------------------------------- // document events //------------------------------------------------------------------------- // capture the percentage read, a number from 0.0 to 1.0 // capture the current time of this scroll event onScroll() { var percent = (this.frame.scrollTop + this.frame.offsetHeight) / this.frame.scrollHeight; percent = Math.min(percent.toFixed(2), 1.00); // this may be greater than 1.00 if there are top and bottom borders, so normalize it back to 1.00 this.percentRead = Math.max(percent, this.percentRead); // if the user has scrolled up, and away from the bottom, retain the larger value if (this.firstScrollTime == null) this.firstScrollTime = Date.now(); else this.lastScrollTime = Date.now(); } //------------------------------------------------------------------------- // component methods //------------------------------------------------------------------------- // do not show the fly-in panel if the data- attributes were not properly set show() { if (!this.hasValidSetup) return; // re-insert the customElement to be just below the #objectives positioner this.positioner.after(this); this.panel.classList.remove('hide'); this.panel.classList.add('show'); } //< returns time in seconds // The firstChunk is the amount of time between page load and the first scrolling event. // The lastChunk is the amount of time between page load and the final scrolling event. // In both cases, assume that the amount of reading time after the last scrolling event is equal to the firstChunk // Cap the reading time at two times the suggested reading time. calculateReadingTime() { var cappedReadingTime = (2 * this.suggestedReadingTime); if (this.firstScrollTime == null) return 0; var firstChunk = Math.round((this.firstScrollTime - this.loadTime) / 1000); if (this.lastScrollTime == null) return Math.min(firstChunk + firstChunk, cappedReadingTime); var lastChunk = Math.round((this.lastScrollTime - this.loadTime) / 1000); return Math.min(lastChunk + firstChunk, cappedReadingTime); } } window.customElements.define(Static.componentName, RwtReadingPoints);