AnthoLume/assets/reader/index.js
Evan Reichard 0ac00dfa41
All checks were successful
continuous-integration/drone/push Build is passing
[fix] xpath & cfi resolution
2023-11-07 19:18:34 -05:00

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