Evan Reichard
0ac00dfa41
All checks were successful
continuous-integration/drone/push Build is passing
1226 lines
35 KiB
JavaScript
1226 lines
35 KiB
JavaScript
const THEMES = ["light", "tan", "blue", "gray", "black"];
|
|
const THEME_FILE = "/assets/reader/readerThemes.css";
|
|
|
|
/**
|
|
* Initial load handler. Gets called on DOMContentLoaded. Responsible for
|
|
* normalizing the documentData depending on type (REMOTE or LOCAL), and
|
|
* populating the metadata of the book into the DOM.
|
|
**/
|
|
async function initReader() {
|
|
let documentData;
|
|
let filePath;
|
|
|
|
// Get Document ID & Type
|
|
const urlParams = new URLSearchParams(window.location.hash.slice(1));
|
|
const documentID = urlParams.get("id");
|
|
const documentType = urlParams.get("type");
|
|
|
|
if (documentType == "REMOTE") {
|
|
// Get Server / Cached Document
|
|
let progressResp = await fetch("/documents/" + documentID + "/progress");
|
|
documentData = await progressResp.json();
|
|
|
|
// Update With Local Cache
|
|
let localCache = await IDB.get("PROGRESS-" + documentID);
|
|
if (localCache) {
|
|
documentData.progress = localCache.progress;
|
|
documentData.percentage = Math.round(localCache.percentage * 10000) / 100;
|
|
}
|
|
|
|
filePath = "/documents/" + documentID + "/file";
|
|
} else if (documentType == "LOCAL") {
|
|
documentData = await IDB.get("FILE-METADATA-" + documentID);
|
|
let fileBlob = await IDB.get("FILE-" + documentID);
|
|
filePath = URL.createObjectURL(fileBlob);
|
|
} else {
|
|
throw new Error("Invalid Type");
|
|
}
|
|
|
|
// Update Type
|
|
documentData.type = documentType;
|
|
|
|
// Populate Metadata & Create Reader
|
|
window.currentReader = new EBookReader(filePath, documentData);
|
|
populateMetadata(documentData);
|
|
}
|
|
|
|
/**
|
|
* Populates metadata into the DOM. Specifically for the top "drop" down.
|
|
**/
|
|
function populateMetadata(data) {
|
|
let documentLocation =
|
|
data.type == "LOCAL" ? "/local" : "/documents/" + data.id;
|
|
|
|
let documentCoverLocation =
|
|
data.type == "LOCAL"
|
|
? "/assets/images/no-cover.jpg"
|
|
: "/documents/" + data.id + "/cover";
|
|
|
|
let [backEl, coverEl] = document.querySelectorAll("a");
|
|
backEl.setAttribute("href", documentLocation);
|
|
coverEl.setAttribute("href", documentLocation);
|
|
coverEl.firstElementChild.setAttribute("src", documentCoverLocation);
|
|
|
|
let [titleEl, authorEl] = document.querySelectorAll("#top-bar p + p");
|
|
titleEl.innerText = data.title;
|
|
authorEl.innerText = data.author;
|
|
}
|
|
|
|
/**
|
|
* This is the main reader class. All functionality is wrapped in this class.
|
|
* Responsible for handling gesture / clicks, flushing progress & activity,
|
|
* storing and processing themes, etc.
|
|
**/
|
|
class EBookReader {
|
|
bookState = {
|
|
pages: 0,
|
|
percentage: 0,
|
|
progress: "",
|
|
progressElement: null,
|
|
readActivity: [],
|
|
words: 0,
|
|
};
|
|
|
|
constructor(file, bookState) {
|
|
// Set Variables
|
|
Object.assign(this.bookState, bookState);
|
|
|
|
// Load Settings
|
|
this.loadSettings();
|
|
|
|
// Load EPUB
|
|
this.book = ePub(file, { openAs: "epub" });
|
|
|
|
// Render
|
|
this.rendition = this.book.renderTo("viewer", {
|
|
manager: "default",
|
|
flow: "paginated",
|
|
width: "100%",
|
|
height: "100%",
|
|
});
|
|
|
|
// Setup Reader
|
|
this.book.ready.then(this.setupReader.bind(this));
|
|
|
|
// Initialize
|
|
this.initDevice();
|
|
this.initWakeLock();
|
|
this.initThemes();
|
|
this.initRenditionListeners();
|
|
this.initDocumentListeners();
|
|
}
|
|
|
|
/**
|
|
* Load progress and generate locations
|
|
**/
|
|
async setupReader() {
|
|
// Get Word Count
|
|
this.bookState.words = await this.countWords();
|
|
|
|
// Load Progress
|
|
let { cfi } = await this.getCFIFromXPath(this.bookState.progress);
|
|
|
|
// Update Position
|
|
await this.setPosition(cfi);
|
|
|
|
// Highlight Element - DOM Has Element
|
|
let { element } = await this.getCFIFromXPath(this.bookState.progress);
|
|
|
|
// Set Progress Element & Highlight
|
|
this.bookState.progressElement = element;
|
|
this.highlightPositionMarker();
|
|
|
|
// Update Stats & Page Start
|
|
let stats = await this.getBookStats();
|
|
this.updateBookStatElements(stats);
|
|
this.bookState.pageStart = Date.now();
|
|
}
|
|
|
|
initDevice() {
|
|
function randomID() {
|
|
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
|
|
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
|
|
.toString(16)
|
|
.toUpperCase()
|
|
);
|
|
}
|
|
|
|
this.readerSettings.deviceName =
|
|
this.readerSettings.deviceName ||
|
|
platform.os.toString() + " - " + platform.name;
|
|
|
|
this.readerSettings.deviceID = this.readerSettings.deviceID || randomID();
|
|
|
|
// Save Settings (Device ID)
|
|
this.saveSettings();
|
|
}
|
|
|
|
/**
|
|
* This is a hack and maintains a wake lock. It will automatically disable
|
|
* if there's been no input for 10 minutes.
|
|
*
|
|
* Ideally we use "navigator.wakeLock", but there's a bug in Safari (as of
|
|
* iOS 17.03) when intalled as a PWA that doesn't allow it to work [0]
|
|
*
|
|
* Unfortunate downside is iOS indicates that "No Sleep" is playing in both
|
|
* the Control Center and Lock Screen. iOS also stops any background sound.
|
|
*
|
|
* [0] https://progressier.com/pwa-capabilities/screen-wake-lock
|
|
**/
|
|
initWakeLock() {
|
|
// Setup Wake Lock (Adding to DOM Necessary - iOS 17.03)
|
|
let timeoutID = null;
|
|
let wakeLock = new NoSleep();
|
|
|
|
// Override Standalone (Modified No-Sleep)
|
|
if (window.navigator.standalone) {
|
|
Object.assign(wakeLock.noSleepVideo.style, {
|
|
position: "absolute",
|
|
top: "-100%",
|
|
});
|
|
document.body.append(wakeLock.noSleepVideo);
|
|
}
|
|
|
|
// User Action Required (Manual bubble up from iFrame)
|
|
document.addEventListener("wakelock", function () {
|
|
// 10 Minute Timeout
|
|
if (timeoutID) clearTimeout(timeoutID);
|
|
timeoutID = setTimeout(wakeLock.disable, 1000 * 60 * 10);
|
|
|
|
// Enable
|
|
wakeLock.enable();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Register all themes with reader
|
|
**/
|
|
initThemes() {
|
|
// Register Themes
|
|
THEMES.forEach((theme) =>
|
|
this.rendition.themes.register(theme, THEME_FILE)
|
|
);
|
|
|
|
let themeLinkEl = document.createElement("link");
|
|
themeLinkEl.setAttribute("id", "themes");
|
|
themeLinkEl.setAttribute("rel", "stylesheet");
|
|
themeLinkEl.setAttribute("href", THEME_FILE);
|
|
document.head.append(themeLinkEl);
|
|
|
|
// Set Theme Style
|
|
this.rendition.themes.default({
|
|
"*": {
|
|
"font-size": "var(--editor-font-size) !important",
|
|
"font-family": "var(--editor-font-family) !important",
|
|
},
|
|
});
|
|
|
|
// Restore Theme Hook
|
|
this.rendition.hooks.content.register(
|
|
function () {
|
|
// Restore Theme
|
|
this.setTheme();
|
|
|
|
// Set Fonts - TODO: Local
|
|
// https://gwfh.mranftl.com/fonts
|
|
this.rendition.getContents().forEach((c) => {
|
|
[
|
|
"https://fonts.googleapis.com/css?family=Arbutus+Slab",
|
|
"https://fonts.googleapis.com/css?family=Open+Sans",
|
|
"https://fonts.googleapis.com/css?family=Lato:400,400i,700,700i",
|
|
].forEach((url) => {
|
|
let el = c.document.head.appendChild(
|
|
c.document.createElement("link")
|
|
);
|
|
el.setAttribute("rel", "stylesheet");
|
|
el.setAttribute("href", url);
|
|
});
|
|
});
|
|
}.bind(this)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set theme & meta theme color
|
|
**/
|
|
setTheme(newTheme) {
|
|
// Assert Theme Object
|
|
this.readerSettings.theme =
|
|
typeof this.readerSettings.theme == "object"
|
|
? this.readerSettings.theme
|
|
: {};
|
|
|
|
// Assign Values
|
|
Object.assign(this.readerSettings.theme, newTheme);
|
|
|
|
// Get Desired Theme (Defaults)
|
|
let colorScheme = this.readerSettings.theme.colorScheme || "tan";
|
|
let fontFamily = this.readerSettings.theme.fontFamily || "serif";
|
|
let fontSize = this.readerSettings.theme.fontSize || 1;
|
|
|
|
// Set Reader Theme
|
|
this.rendition.themes.select(colorScheme);
|
|
|
|
// Get Reader Theme
|
|
let themeColorEl = document.querySelector("[name='theme-color']");
|
|
let themeStyleSheet = document.querySelector("#themes").sheet;
|
|
let themeStyleRule = Array.from(themeStyleSheet.cssRules).find(
|
|
(item) => item.selectorText == "." + colorScheme
|
|
);
|
|
|
|
// Match Reader Theme
|
|
if (!themeStyleRule) return;
|
|
let backgroundColor = themeStyleRule.style.backgroundColor;
|
|
themeColorEl.setAttribute("content", backgroundColor);
|
|
document.body.style.backgroundColor = backgroundColor;
|
|
|
|
// Set Font Family & Highlight Style
|
|
this.rendition.getContents().forEach((item) => {
|
|
// Set Font Family
|
|
item.document.documentElement.style.setProperty(
|
|
"--editor-font-family",
|
|
fontFamily
|
|
);
|
|
|
|
// Set Font Size
|
|
item.document.documentElement.style.setProperty(
|
|
"--editor-font-size",
|
|
fontSize + "em"
|
|
);
|
|
|
|
// Set Highlight Style
|
|
item.document.querySelectorAll(".highlight").forEach((el) => {
|
|
Object.assign(el.style, {
|
|
background: backgroundColor,
|
|
});
|
|
});
|
|
});
|
|
|
|
// Save Settings (Theme)
|
|
this.saveSettings();
|
|
}
|
|
|
|
/**
|
|
* Takes existing progressElement and applies the highlight style to it.
|
|
* This is nice when font size or font family changes as it can cause
|
|
* the position to move.
|
|
**/
|
|
highlightPositionMarker() {
|
|
if (!this.bookState.progressElement) return;
|
|
|
|
// Remove Existing
|
|
this.rendition.getContents().forEach((item) => {
|
|
item.document.querySelectorAll(".highlight").forEach((el) => {
|
|
el.removeAttribute("style");
|
|
el.classList.remove("highlight");
|
|
});
|
|
});
|
|
|
|
// Compute Style
|
|
let backgroundColor = getComputedStyle(
|
|
this.bookState.progressElement.ownerDocument.body
|
|
).backgroundColor;
|
|
|
|
// Set Style
|
|
Object.assign(this.bookState.progressElement.style, {
|
|
background: backgroundColor,
|
|
filter: "invert(0.2)",
|
|
});
|
|
|
|
// Update Class
|
|
this.bookState.progressElement.classList.add("highlight");
|
|
}
|
|
|
|
/**
|
|
* Rendition hooks
|
|
**/
|
|
initRenditionListeners() {
|
|
/**
|
|
* Initiate the debounce when the given function returns true.
|
|
* Don't run it again until the timeout lapses.
|
|
**/
|
|
function debounceFunc(fn, d) {
|
|
let timer;
|
|
let bouncing = false;
|
|
return function () {
|
|
let context = this;
|
|
let args = arguments;
|
|
|
|
if (bouncing) return;
|
|
if (!fn.apply(context, args)) return;
|
|
|
|
bouncing = true;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
bouncing = false;
|
|
}, d);
|
|
};
|
|
}
|
|
|
|
// Elements
|
|
let topBar = document.querySelector("#top-bar");
|
|
let bottomBar = document.querySelector("#bottom-bar");
|
|
|
|
// Local Functions
|
|
let getCFIFromXPath = this.getCFIFromXPath.bind(this);
|
|
let setPosition = this.setPosition.bind(this);
|
|
let nextPage = this.nextPage.bind(this);
|
|
let prevPage = this.prevPage.bind(this);
|
|
let saveSettings = this.saveSettings.bind(this);
|
|
|
|
// Local Vars
|
|
let readerSettings = this.readerSettings;
|
|
let bookState = this.bookState;
|
|
|
|
this.rendition.hooks.render.register(function (doc, data) {
|
|
let renderDoc = doc.document;
|
|
|
|
// ------------------------------------------------ //
|
|
// ---------------- Wake Lock Hack ---------------- //
|
|
// ------------------------------------------------ //
|
|
let wakeLockListener = function () {
|
|
doc.window.parent.document.dispatchEvent(new CustomEvent("wakelock"));
|
|
};
|
|
renderDoc.addEventListener("click", wakeLockListener);
|
|
renderDoc.addEventListener("gesturechange", wakeLockListener);
|
|
renderDoc.addEventListener("touchstart", wakeLockListener);
|
|
|
|
// ------------------------------------------------ //
|
|
// --------------- Swipe Pagination --------------- //
|
|
// ------------------------------------------------ //
|
|
let touchStartX,
|
|
touchStartY,
|
|
touchEndX,
|
|
touchEndY = undefined;
|
|
|
|
renderDoc.addEventListener(
|
|
"touchstart",
|
|
function (event) {
|
|
touchStartX = event.changedTouches[0].screenX;
|
|
touchStartY = event.changedTouches[0].screenY;
|
|
},
|
|
false
|
|
);
|
|
|
|
renderDoc.addEventListener(
|
|
"touchend",
|
|
function (event) {
|
|
touchEndX = event.changedTouches[0].screenX;
|
|
touchEndY = event.changedTouches[0].screenY;
|
|
handleGesture(event);
|
|
},
|
|
false
|
|
);
|
|
|
|
function handleGesture(event) {
|
|
let drasticity = 75;
|
|
|
|
// Swipe Down
|
|
if (touchEndY - drasticity > touchStartY) {
|
|
return handleSwipeDown();
|
|
}
|
|
|
|
// Swipe Up
|
|
if (touchEndY + drasticity < touchStartY) {
|
|
// Prioritize Down & Up Swipes
|
|
return handleSwipeUp();
|
|
}
|
|
|
|
// Swipe Left
|
|
if (touchEndX + drasticity < touchStartX) {
|
|
nextPage();
|
|
}
|
|
|
|
// Swipe Right
|
|
if (touchEndX - drasticity > touchStartX) {
|
|
prevPage();
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------ //
|
|
// --------------- Bottom & Top Bar --------------- //
|
|
// ------------------------------------------------ //
|
|
renderDoc.addEventListener(
|
|
"click",
|
|
function (event) {
|
|
// Get Window Dimensions
|
|
let windowWidth = window.innerWidth;
|
|
let windowHeight = window.innerHeight;
|
|
|
|
// Calculate X & Y Hot Zones
|
|
let barPixels = windowHeight * 0.2;
|
|
let pagePixels = windowWidth * 0.2;
|
|
|
|
// Calculate Top & Bottom Thresholds
|
|
let top = barPixels;
|
|
let bottom = window.innerHeight - top;
|
|
|
|
// Calculate Left & Right Thresholds
|
|
let left = pagePixels;
|
|
let right = windowWidth - left;
|
|
|
|
// Calculate Relative Coords
|
|
let leftOffset = this.views().container.scrollLeft;
|
|
let yCoord = event.clientY;
|
|
let xCoord = event.clientX - leftOffset;
|
|
|
|
// Handle Event
|
|
if (yCoord < top) handleSwipeDown();
|
|
else if (yCoord > bottom) handleSwipeUp();
|
|
else if (xCoord < left) prevPage();
|
|
else if (xCoord > right) nextPage();
|
|
else {
|
|
bottomBar.classList.remove("bottom-0");
|
|
topBar.classList.remove("top-0");
|
|
}
|
|
}.bind(this)
|
|
);
|
|
|
|
renderDoc.addEventListener(
|
|
"wheel",
|
|
debounceFunc((event) => {
|
|
if (event.deltaY > 25) {
|
|
handleSwipeUp();
|
|
return true;
|
|
}
|
|
if (event.deltaY < -25) {
|
|
handleSwipeDown();
|
|
return true;
|
|
}
|
|
}, 400)
|
|
);
|
|
|
|
function handleSwipeDown() {
|
|
if (bottomBar.classList.contains("bottom-0"))
|
|
bottomBar.classList.remove("bottom-0");
|
|
else topBar.classList.add("top-0");
|
|
}
|
|
|
|
function handleSwipeUp() {
|
|
if (topBar.classList.contains("top-0"))
|
|
topBar.classList.remove("top-0");
|
|
else bottomBar.classList.add("bottom-0");
|
|
}
|
|
|
|
// ------------------------------------------------ //
|
|
// -------------- Keyboard Shortcuts -------------- //
|
|
// ------------------------------------------------ //
|
|
renderDoc.addEventListener(
|
|
"keyup",
|
|
function (e) {
|
|
// Left Key (Previous Page)
|
|
if ((e.keyCode || e.which) == 37) {
|
|
prevPage();
|
|
}
|
|
|
|
// Right Key (Next Page)
|
|
if ((e.keyCode || e.which) == 39) {
|
|
nextPage();
|
|
}
|
|
|
|
// "t" Key (Theme Cycle)
|
|
if ((e.keyCode || e.which) == 84) {
|
|
let currentThemeIdx = THEMES.indexOf(
|
|
readerSettings.theme.colorScheme
|
|
);
|
|
let colorScheme =
|
|
THEMES.length == currentThemeIdx + 1
|
|
? THEMES[0]
|
|
: THEMES[currentThemeIdx + 1];
|
|
setTheme({ colorScheme });
|
|
}
|
|
},
|
|
false
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Document listeners
|
|
**/
|
|
initDocumentListeners() {
|
|
// Elements
|
|
let topBar = document.querySelector("#top-bar");
|
|
|
|
let nextPage = this.nextPage.bind(this);
|
|
let prevPage = this.prevPage.bind(this);
|
|
|
|
// Keyboard Shortcuts
|
|
document.addEventListener(
|
|
"keyup",
|
|
function (e) {
|
|
// Left Key (Previous Page)
|
|
if ((e.keyCode || e.which) == 37) {
|
|
prevPage();
|
|
}
|
|
|
|
// Right Key (Next Page)
|
|
if ((e.keyCode || e.which) == 39) {
|
|
nextPage();
|
|
}
|
|
|
|
// "t" Key (Theme Cycle)
|
|
if ((e.keyCode || e.which) == 84) {
|
|
let currentThemeIdx = THEMES.indexOf(
|
|
this.readerSettings.theme.colorScheme
|
|
);
|
|
let colorScheme =
|
|
THEMES.length == currentThemeIdx + 1
|
|
? THEMES[0]
|
|
: THEMES[currentThemeIdx + 1];
|
|
this.setTheme({ colorScheme });
|
|
}
|
|
}.bind(this),
|
|
false
|
|
);
|
|
|
|
// Color Scheme Switcher
|
|
document.querySelectorAll(".color-scheme").forEach(
|
|
function (item) {
|
|
item.addEventListener(
|
|
"click",
|
|
function (event) {
|
|
let colorScheme = event.target.innerText;
|
|
this.setTheme({ colorScheme });
|
|
}.bind(this)
|
|
);
|
|
}.bind(this)
|
|
);
|
|
|
|
// Font Switcher
|
|
document.querySelectorAll(".font-family").forEach(
|
|
function (item) {
|
|
item.addEventListener(
|
|
"click",
|
|
async function (event) {
|
|
let { cfi } = await this.getCFIFromXPath(this.bookState.progress);
|
|
|
|
let fontFamily = event.target.innerText;
|
|
this.setTheme({ fontFamily });
|
|
|
|
this.setPosition(cfi);
|
|
}.bind(this)
|
|
);
|
|
}.bind(this)
|
|
);
|
|
|
|
// Font Size
|
|
document.querySelectorAll(".font-size").forEach(
|
|
function (item) {
|
|
item.addEventListener(
|
|
"click",
|
|
async function (event) {
|
|
// Get Initial CFI
|
|
let { cfi } = await this.getCFIFromXPath(this.bookState.progress);
|
|
|
|
// Modify Size
|
|
let currentSize = this.readerSettings.theme.fontSize || 1;
|
|
let direction = event.target.innerText;
|
|
if (direction == "-") {
|
|
this.setTheme({ fontSize: currentSize * 0.99 });
|
|
} else if (direction == "+") {
|
|
this.setTheme({ fontSize: currentSize * 1.01 });
|
|
}
|
|
|
|
// Restore CFI
|
|
this.setPosition(cfi);
|
|
}.bind(this)
|
|
);
|
|
}.bind(this)
|
|
);
|
|
|
|
// Close Top Bar
|
|
document.querySelector(".close-top-bar").addEventListener("click", () => {
|
|
topBar.classList.remove("top-0");
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Progresses to the next page & monitors reading activity
|
|
**/
|
|
async nextPage() {
|
|
// Create Activity
|
|
await this.createActivity();
|
|
|
|
// Render Next Page
|
|
await this.rendition.next();
|
|
|
|
// Reset Read Timer
|
|
this.bookState.pageStart = Date.now();
|
|
|
|
// Update Stats
|
|
let stats = await this.getBookStats();
|
|
this.updateBookStatElements(stats);
|
|
|
|
// Create Progress
|
|
this.createProgress();
|
|
}
|
|
|
|
/**
|
|
* Progresses to the previous page & monitors reading activity
|
|
**/
|
|
async prevPage() {
|
|
// Render Previous Page
|
|
await this.rendition.prev();
|
|
|
|
// Reset Read Timer
|
|
this.bookState.pageStart = Date.now();
|
|
|
|
// Update Stats
|
|
let stats = await this.getBookStats();
|
|
this.updateBookStatElements(stats);
|
|
|
|
// Create Progress
|
|
this.createProgress();
|
|
}
|
|
|
|
/**
|
|
* Display @ CFI x 3 (Hack)
|
|
*
|
|
* This is absurd. Only way to get it to consistently show the correct
|
|
* page is to execute this three times. I tried the font hook,
|
|
* rendition hook, relocated hook, etc. No reliable way outside of
|
|
* running this three times.
|
|
*
|
|
* Likely Bug: https://github.com/futurepress/epub.js/issues/1194
|
|
**/
|
|
async setPosition(cfi) {
|
|
await this.rendition.display(cfi);
|
|
await this.rendition.display(cfi);
|
|
await this.rendition.display(cfi);
|
|
|
|
this.highlightPositionMarker();
|
|
}
|
|
|
|
async createActivity() {
|
|
// WPM MAX & MIN
|
|
const WPM_MAX = 2000;
|
|
const WPM_MIN = 100;
|
|
|
|
// Get Elapsed Time
|
|
let pageStart = this.bookState.pageStart;
|
|
let elapsedTime = Date.now() - pageStart;
|
|
|
|
// Update Current Word
|
|
let pageWords = await this.getVisibleWordCount();
|
|
let currentWord = await this.getBookWordPosition();
|
|
let percentRead = pageWords / this.bookState.words;
|
|
|
|
let pageWPM = pageWords / (elapsedTime / 60000);
|
|
console.log("[createActivity] Page WPM:", pageWPM);
|
|
|
|
// Exclude Ridiculous WPM
|
|
if (pageWPM >= WPM_MAX)
|
|
return console.log(
|
|
"[createActivity] Page WPM Exceeds Max (2000):",
|
|
pageWPM
|
|
);
|
|
|
|
// Ensure WPM Minimum
|
|
if (pageWPM < WPM_MIN) elapsedTime = (pageWords / WPM_MIN) * 60000;
|
|
|
|
let totalPages = Math.round(1 / percentRead);
|
|
|
|
// Exclude 0 Pages
|
|
if (totalPages == 0)
|
|
return console.warn("[createActivity] Invalid Total Pages (0)");
|
|
|
|
let currentPage = Math.round(
|
|
(currentWord * totalPages) / this.bookState.words
|
|
);
|
|
|
|
// Create Activity Event
|
|
let activityEvent = {
|
|
device_id: this.readerSettings.deviceID,
|
|
device: this.readerSettings.deviceName,
|
|
activity: [
|
|
{
|
|
document: this.bookState.id,
|
|
duration: Math.round(elapsedTime / 1000),
|
|
start_time: Math.round(pageStart / 1000),
|
|
page: currentPage,
|
|
pages: totalPages,
|
|
},
|
|
],
|
|
};
|
|
|
|
// Local Files
|
|
if (this.bookState.type == "LOCAL") return;
|
|
|
|
// Remote Flush -> Offline Cache IDB
|
|
this.flushActivity(activityEvent).catch(async (e) => {
|
|
console.error("[createActivity] Activity Flush Failed:", {
|
|
error: e,
|
|
data: activityEvent,
|
|
});
|
|
|
|
// Get & Update Activity
|
|
let existingActivity = await IDB.get("ACTIVITY", { activity: [] });
|
|
existingActivity.device_id = activityEvent.device_id;
|
|
existingActivity.device = activityEvent.device;
|
|
existingActivity.activity.push(...activityEvent.activity);
|
|
|
|
// Update IDB
|
|
await IDB.set("ACTIVITY", existingActivity);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Normalize and flush activity
|
|
**/
|
|
flushActivity(activityEvent) {
|
|
console.log("[flushActivity] Flushing Activity...");
|
|
|
|
// Flush Activity
|
|
return fetch("/api/ko/activity", {
|
|
method: "POST",
|
|
body: JSON.stringify(activityEvent),
|
|
}).then(async (r) =>
|
|
console.log("[flushActivity] Flushed Activity:", {
|
|
response: r,
|
|
json: await r.json(),
|
|
data: activityEvent,
|
|
})
|
|
);
|
|
}
|
|
|
|
async createProgress() {
|
|
// Update Pointers
|
|
let currentCFI = await this.rendition.currentLocation();
|
|
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
|
let currentWord = await this.getBookWordPosition();
|
|
console.log("[createProgress] Current Word:", currentWord);
|
|
this.bookState.progress = xpath;
|
|
this.bookState.progressElement = element;
|
|
|
|
// Create Event
|
|
let progressEvent = {
|
|
document: this.bookState.id,
|
|
device_id: this.readerSettings.deviceID,
|
|
device: this.readerSettings.deviceName,
|
|
percentage:
|
|
Math.round((currentWord / this.bookState.words) * 100000) / 100000,
|
|
progress: this.bookState.progress,
|
|
};
|
|
|
|
// Update Local Metadata
|
|
if (this.bookState.type == "LOCAL") {
|
|
let currentMetadata = await IDB.get("FILE-METADATA-" + this.bookState.id);
|
|
return IDB.set("FILE-METADATA-" + this.bookState.id, {
|
|
...currentMetadata,
|
|
progress: progressEvent.progress,
|
|
percentage: Math.round(progressEvent.percentage * 10000) / 100,
|
|
words: this.bookState.words,
|
|
});
|
|
}
|
|
|
|
// Remote Flush -> Offline Cache IDB
|
|
this.flushProgress(progressEvent).catch(async (e) => {
|
|
console.error("[createProgress] Progress Flush Failed:", {
|
|
error: e,
|
|
data: progressEvent,
|
|
});
|
|
|
|
// Update IDB
|
|
await IDB.set("PROGRESS-" + progressEvent.document, progressEvent);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Flush progress to the API. Called when the page changes.
|
|
**/
|
|
flushProgress(progressEvent) {
|
|
console.log("[flushProgress] Flushing Progress...");
|
|
|
|
// Flush Progress
|
|
return fetch("/api/ko/syncs/progress", {
|
|
method: "PUT",
|
|
body: JSON.stringify(progressEvent),
|
|
}).then(async (r) =>
|
|
console.log("[flushProgress] Flushed Progress:", {
|
|
response: r,
|
|
json: await r.json(),
|
|
data: progressEvent,
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Derive chapter current page and total pages
|
|
**/
|
|
sectionProgress() {
|
|
let visibleItems = this.rendition.manager.visible();
|
|
if (visibleItems.length == 0)
|
|
return console.log("[sectionProgress] No Items");
|
|
let visibleSection = visibleItems[0];
|
|
let visibleIndex = visibleSection.index;
|
|
let pagesPerBlock = visibleSection.layout.divisor;
|
|
let totalBlocks = visibleSection.width() / visibleSection.layout.width;
|
|
let sectionPages = totalBlocks;
|
|
|
|
let leftOffset = this.rendition.views().container.scrollLeft;
|
|
let sectionCurrentPage =
|
|
Math.round(leftOffset / visibleSection.layout.width) + 1;
|
|
|
|
return { sectionPages, sectionCurrentPage };
|
|
}
|
|
|
|
/**
|
|
* Get chapter pages, name and progress percentage
|
|
**/
|
|
async getBookStats() {
|
|
let currentProgress = this.sectionProgress();
|
|
if (!currentProgress) return;
|
|
let { sectionPages, sectionCurrentPage } = currentProgress;
|
|
|
|
let currentLocation = this.rendition.currentLocation();
|
|
let currentWord = await this.getBookWordPosition();
|
|
|
|
let currentTOC = this.book.navigation.toc.find(
|
|
(item) => item.href == currentLocation.start.href
|
|
);
|
|
|
|
return {
|
|
sectionPage: sectionCurrentPage,
|
|
sectionTotalPages: sectionPages,
|
|
chapterName: currentTOC ? currentTOC.label.trim() : "N/A",
|
|
percentage:
|
|
Math.round((currentWord / this.bookState.words) * 10000) / 100,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update elements with stats
|
|
**/
|
|
updateBookStatElements(data) {
|
|
if (!data) return;
|
|
|
|
let chapterStatus = document.querySelector("#chapter-status");
|
|
let progressStatus = document.querySelector("#progress-status");
|
|
let chapterName = document.querySelector("#chapter-name-status");
|
|
let progressBar = document.querySelector("#progress-bar-status");
|
|
|
|
chapterStatus.innerText = `${data.sectionPage} / ${data.sectionTotalPages}`;
|
|
progressStatus.innerText = `${data.percentage}%`;
|
|
progressBar.style.width = data.percentage + "%";
|
|
chapterName.innerText = `${data.chapterName}`;
|
|
}
|
|
|
|
/**
|
|
* Get XPath from current location
|
|
**/
|
|
async getXPathFromCFI(cfi) {
|
|
// Get DocFragment (current book spline index)
|
|
let startCFI = cfi.replace("epubcfi(", "");
|
|
let docFragmentIndex =
|
|
this.book.spine.spineItems.find((item) =>
|
|
startCFI.startsWith(item.cfiBase)
|
|
).index + 1;
|
|
|
|
// Base Progress
|
|
let newPos = "/body/DocFragment[" + docFragmentIndex + "]/body";
|
|
|
|
// Get first visible node
|
|
let contents = this.rendition.getContents()[0];
|
|
let node = contents.range(cfi).startContainer;
|
|
let element = null;
|
|
|
|
// Walk upwards and build progress until body
|
|
let childPos = "";
|
|
while (node.nodeName != "BODY") {
|
|
let ownValue;
|
|
|
|
switch (node.nodeType) {
|
|
case Node.ELEMENT_NODE:
|
|
// Store First Element Node
|
|
if (!element) element = node;
|
|
let relativeIndex =
|
|
Array.from(node.parentNode.children)
|
|
.filter((item) => item.nodeName == node.nodeName)
|
|
.indexOf(node) + 1;
|
|
|
|
ownValue = node.nodeName.toLowerCase() + "[" + relativeIndex + "]";
|
|
break;
|
|
case Node.ATTRIBUTE_NODE:
|
|
ownValue = "@" + node.nodeName;
|
|
break;
|
|
case Node.TEXT_NODE:
|
|
case Node.CDATA_SECTION_NODE:
|
|
ownValue = "text()";
|
|
break;
|
|
case Node.PROCESSING_INSTRUCTION_NODE:
|
|
ownValue = "processing-instruction()";
|
|
break;
|
|
case Node.COMMENT_NODE:
|
|
ownValue = "comment()";
|
|
break;
|
|
case Node.DOCUMENT_NODE:
|
|
ownValue = "";
|
|
break;
|
|
default:
|
|
ownValue = "";
|
|
break;
|
|
}
|
|
|
|
// Prepend childPos & Update node reference
|
|
childPos = "/" + ownValue + childPos;
|
|
node = node.parentNode;
|
|
}
|
|
|
|
let xpath = newPos + childPos;
|
|
|
|
// Return derived progress
|
|
return { xpath, element };
|
|
}
|
|
|
|
/**
|
|
* Get CFI from current location
|
|
**/
|
|
async getCFIFromXPath(xpath) {
|
|
// XPath Reference - Example: /body/DocFragment[15]/body/div[10]/text().184
|
|
//
|
|
// - /body/DocFragment[15] = 15th item in book spline
|
|
// - [...]/body/div[10] = 10th child div under body (direct descendents only)
|
|
// - [...]/text().184 = text node of parent, character offset @ 184 chars?
|
|
|
|
// No XPath
|
|
if (!xpath || xpath == "") return {};
|
|
|
|
// Match Document Fragment Index
|
|
let fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
|
if (!fragMatch) {
|
|
console.warn("No XPath Match");
|
|
return {};
|
|
}
|
|
|
|
// Match Item Index
|
|
let indexMatch = xpath.match(/\.(\d+)$/);
|
|
let itemIndex = indexMatch ? parseInt(indexMatch[1]) : 0;
|
|
|
|
// Get Spine Item
|
|
let spinePosition = parseInt(fragMatch[1]) - 1;
|
|
let sectionItem = this.book.spine.get(spinePosition);
|
|
await sectionItem.load(this.book.load.bind(this.book));
|
|
|
|
/**
|
|
* Prefer Document Rendered over Document Not Rendered
|
|
*
|
|
* If the rendition is not displayed, the document does not exist in the
|
|
* DOM. Since we return the matching element for potential theming, we
|
|
* want to first at least try to get the document that exists in the DOM.
|
|
*
|
|
* This is only relevant on initial load and on font resize when we theme
|
|
* the element to indicate to the user the last position, and is why we run
|
|
* this function twice in the setupReader function; once before render to
|
|
* get CFI, and once after render to get the actual element in the DOM to
|
|
* theme.
|
|
**/
|
|
let docItem =
|
|
this.rendition
|
|
.getContents()
|
|
.find((item) => item.sectionIndex == spinePosition)?.document ||
|
|
sectionItem.document;
|
|
|
|
// Derive Namespace & XPath
|
|
let namespaceURI = docItem.documentElement.namespaceURI;
|
|
let remainingXPath = xpath
|
|
// Replace with new base
|
|
.replace(fragMatch[0], "/html")
|
|
// Replace `.0` Ending Indexes
|
|
.replace(/\.(\d+)$/, "")
|
|
// Remove potential trailing `text()`
|
|
.replace(/\/text\(\)(\[\d+\])?$/, "");
|
|
|
|
// XPath to CSS Selector
|
|
let derivedSelector = remainingXPath
|
|
.replace(/^\/html\/body/, "body")
|
|
.replace(/\/(\w+)\[(\d+)\]/g, " $1:nth-of-type($2)")
|
|
.replace(/\//g, " ");
|
|
|
|
// Validate Namespace
|
|
if (namespaceURI) remainingXPath = remainingXPath.replaceAll("/", "/ns:");
|
|
|
|
// Perform XPath
|
|
let docSearch = docItem.evaluate(
|
|
remainingXPath,
|
|
docItem,
|
|
function (prefix) {
|
|
if (prefix === "ns") {
|
|
return namespaceURI;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
);
|
|
|
|
/**
|
|
* There are two ways to do this. One via XPath, and the other via derived
|
|
* CSS selectors. Unfortunately it seems like KOReaders XPath implementation
|
|
* is a little wonky, requiring the need for CSS Selectors.
|
|
*
|
|
* For example the following XPath was generated by KOReader:
|
|
* "/body/DocFragment[19]/body/h1/img.0"
|
|
*
|
|
* In reality, the XPath should have been (note the 'a'):
|
|
* "/body/DocFragment[19]/body/h1/a/img.0"
|
|
*
|
|
* Unfortunately due to the above, `docItem.evaluate` will not find the
|
|
* element. So as an alternative I thought it would be possible to derive
|
|
* a CSS selector. I think this should be fully comprehensive; AFAICT
|
|
* KOReader only creates XPaths referencing HTML tag names and indexes.
|
|
**/
|
|
|
|
// Get Element & CFI (XPath -> CSS Selector Fallback)
|
|
let element =
|
|
docSearch.iterateNext() || docItem.querySelector(derivedSelector);
|
|
|
|
let cfi = sectionItem.cfiFromElement(element);
|
|
|
|
return { cfi, element };
|
|
}
|
|
|
|
/**
|
|
* Get visible word count - used for reading stats
|
|
**/
|
|
async getVisibleWordCount() {
|
|
let visibleText = await this.getVisibleText();
|
|
return visibleText.trim().split(/\s+/).length;
|
|
}
|
|
|
|
/**
|
|
* Gets the word number of the whole book for the first visible word.
|
|
**/
|
|
async getBookWordPosition() {
|
|
// Get Contents & Spine
|
|
let contents = this.rendition.getContents()[0];
|
|
let spineItem = this.book.spine.get(contents.sectionIndex);
|
|
|
|
// Get CFI Range
|
|
let firstCFI = spineItem.cfiFromElement(
|
|
spineItem.document.body.children[0]
|
|
);
|
|
let currentLocation = await this.rendition.currentLocation();
|
|
let cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi);
|
|
|
|
// Get Chapter Text (Before Current Position)
|
|
let textRange = await this.book.getRange(cfiRange);
|
|
let chapterText = textRange.toString();
|
|
|
|
// Get Chapter & Book Positions
|
|
let chapterWordPosition = chapterText.trim().split(/\s+/).length;
|
|
let preChapterWordPosition = this.book.spine.spineItems
|
|
.slice(0, contents.sectionIndex)
|
|
.reduce((totalCount, item) => totalCount + item.wordCount, 0);
|
|
|
|
// Return Current Word Pointer
|
|
return chapterWordPosition + preChapterWordPosition;
|
|
}
|
|
|
|
/**
|
|
* Get visible text - used for word counts
|
|
**/
|
|
async getVisibleText() {
|
|
// Force Expand & Resize (Race Condition Issue)
|
|
this.rendition.manager.visible().forEach((item) => item.expand());
|
|
|
|
// Get Start & End CFI
|
|
let currentLocation = await this.rendition.currentLocation();
|
|
const [startCFI, endCFI] = [
|
|
currentLocation.start.cfi,
|
|
currentLocation.end.cfi,
|
|
];
|
|
|
|
// Derive Range & Get Text
|
|
let cfiRange = this.getCFIRange(startCFI, endCFI);
|
|
let textRange = await this.book.getRange(cfiRange);
|
|
let visibleText = textRange.toString();
|
|
|
|
// Split on Whitespace
|
|
return visibleText;
|
|
}
|
|
|
|
/**
|
|
* Given two CFI's, return range
|
|
**/
|
|
getCFIRange(a, b) {
|
|
const CFI = new ePub.CFI();
|
|
const start = CFI.parse(a),
|
|
end = CFI.parse(b);
|
|
const cfi = {
|
|
range: true,
|
|
base: start.base,
|
|
path: {
|
|
steps: [],
|
|
terminal: null,
|
|
},
|
|
start: start.path,
|
|
end: end.path,
|
|
};
|
|
const len = cfi.start.steps.length;
|
|
for (let i = 0; i < len; i++) {
|
|
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
|
|
if (i == len - 1) {
|
|
// Last step is equal, check terminals
|
|
if (cfi.start.terminal === cfi.end.terminal) {
|
|
// CFI's are equal
|
|
cfi.path.steps.push(cfi.start.steps[i]);
|
|
// Not a range
|
|
cfi.range = false;
|
|
}
|
|
} else cfi.path.steps.push(cfi.start.steps[i]);
|
|
} else break;
|
|
}
|
|
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
|
|
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
|
|
|
|
return (
|
|
"epubcfi(" +
|
|
CFI.segmentString(cfi.base) +
|
|
"!" +
|
|
CFI.segmentString(cfi.path) +
|
|
"," +
|
|
CFI.segmentString(cfi.start) +
|
|
"," +
|
|
CFI.segmentString(cfi.end) +
|
|
")"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Count the words of the book. Useful for keeping a more accurate track
|
|
* of progress percentage. Implementation returns the same number as the
|
|
* server side implementation.
|
|
**/
|
|
async countWords() {
|
|
let spineWC = await Promise.all(
|
|
this.book.spine.spineItems.map(async (item) => {
|
|
let newDoc = await item.load(this.book.load.bind(this.book));
|
|
let spineWords = newDoc.innerText.trim().split(/\s+/).length;
|
|
item.wordCount = spineWords;
|
|
return spineWords;
|
|
})
|
|
);
|
|
|
|
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
|
|
}
|
|
|
|
/**
|
|
* Save settings to localStorage
|
|
**/
|
|
saveSettings() {
|
|
if (!this.readerSettings) this.loadSettings();
|
|
localStorage.setItem("readerSettings", JSON.stringify(this.readerSettings));
|
|
}
|
|
|
|
/**
|
|
* Load reader settings from localStorage
|
|
**/
|
|
loadSettings() {
|
|
this.readerSettings = JSON.parse(
|
|
localStorage.getItem("readerSettings") || "{}"
|
|
);
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", initReader);
|