AnthoLume/assets/reader/index.js

656 lines
18 KiB
JavaScript

const THEMES = ["light", "tan", "blue", "gray", "black"];
const THEME_FILE = "/assets/reader/readerThemes.css";
class EBookReader {
bookState = {
currentWord: 0,
pages: 0,
percentage: 0,
progress: "",
readEvents: [],
words: 0,
};
constructor(file, bookState) {
// Set Variables
Object.assign(this.bookState, bookState);
// Load EPUB
this.book = ePub(file, { openAs: "epub" });
window.book = this.book;
// 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));
// Load Settings
this.loadSettings();
// Initialize
this.initThemes();
this.initRenditionListeners();
this.initDocumentListeners();
}
/**
* Load position and generate locations
**/
async setupReader() {
// Load Position
let currentCFI = await this.fromPosition(this.bookState.progress);
await this.rendition.display(currentCFI);
// Start Timer
this.bookState.pageStart = Date.now();
// Get Stats
let getStats = function () {
let stats = this.getBookStats();
this.updateBookStats(stats);
}.bind(this);
// Register Content Hook
this.rendition.hooks.content.register(getStats);
getStats();
}
/**
* Register all themes with reader
**/
initThemes() {
// Register Themes
THEMES.forEach((theme) =>
this.rendition.themes.register(theme, THEME_FILE)
);
this.rendition.themes.select(this.readerSettings.theme || "tan");
}
/**
* 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");
let nextPage = this.nextPage.bind(this);
let prevPage = this.prevPage.bind(this);
let saveSettings = this.saveSettings.bind(this);
// Font Scaling
let readerSettings = this.readerSettings;
this.rendition.hooks.render.register(function (doc, data) {
let renderDoc = doc.document;
// Initial Font Size
renderDoc.documentElement.style.setProperty(
"--editor-font-size",
(readerSettings.fontSize || 1) + "em"
);
this.themes.default({
"*": { "font-size": "var(--editor-font-size) !important" },
});
// ------------------------------------------------ //
// ---------------- Resize Helpers ---------------- //
// ------------------------------------------------ //
let isScaling = false;
let lastScale = 1;
let lastLocation = undefined;
let debounceID = undefined;
let debounceGesture = () => {
this.display(lastLocation.start.cfi);
lastLocation = undefined;
isScaling = false;
};
// Gesture Listener
renderDoc.addEventListener(
"gesturechange",
async function (e) {
e.preventDefault();
isScaling = true;
clearTimeout(debounceID);
if (!lastLocation) {
lastLocation = await this.currentLocation();
} else {
// Damped Scale
readerSettings.fontSize =
(readerSettings.fontSize || 1) + (e.scale - lastScale) / 5;
lastScale = e.scale;
saveSettings();
// Update Font Size
renderDoc.documentElement.style.setProperty(
"--editor-font-size",
(readerSettings.fontSize || 1) + "em"
);
debounceID = setTimeout(debounceGesture, 200);
}
}.bind(this),
true
);
// ------------------------------------------------ //
// --------------- 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;
if (!isScaling) 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 --------------- //
// ------------------------------------------------ //
let emSize = parseFloat(getComputedStyle(renderDoc.body).fontSize);
renderDoc.addEventListener("click", function (event) {
let barPixels = emSize * 5;
let top = barPixels;
let bottom = window.innerHeight - top;
let left = barPixels / 2;
let right = window.innerWidth - left;
if (event.clientY < top) handleSwipeDown();
else if (event.clientY > bottom) handleSwipeUp();
else if (event.screenX < left) prevPage();
else if (event.screenX > right) nextPage();
else {
bottomBar.classList.remove("bottom-0");
topBar.classList.remove("top-0");
}
});
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);
if (THEMES.length == currentThemeIdx + 1)
readerSettings.theme = THEMES[0];
else readerSettings.theme = THEMES[currentThemeIdx + 1];
this.themes.select(readerSettings.theme);
this.setSettings();
}
},
false
);
});
}
/**
* Document listeners
**/
initDocumentListeners() {
// Elements
let topBar = document.querySelector("#top-bar");
let nextPage = this.nextPage.bind(this);
let prevPage = this.prevPage.bind(this);
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);
if (THEMES.length == currentThemeIdx + 1)
this.readerSettings.theme = THEMES[0];
else this.readerSettings.theme = THEMES[currentThemeIdx + 1];
this.rendition.themes.select(readerSettings.theme);
this.setSettings();
}
},
false
);
document.querySelectorAll(".theme").forEach(
function (item) {
item.addEventListener(
"click",
function (event) {
this.readerSettings.theme = event.target.innerText;
this.rendition.themes.select(this.readerSettings.theme);
this.saveSettings();
}.bind(this)
);
}.bind(this)
);
document.querySelector(".close-top-bar").addEventListener("click", () => {
topBar.classList.remove("top-0");
});
}
/**
* Progresses to the next page & monitors reading activity
**/
async nextPage() {
// Get Elapsed Time
let elapsedTime = Date.now() - this.bookState.pageStart;
// Update Current Word
let pageWords = await this.getVisibleWordCount();
let startingWord = this.bookState.currentWord;
this.bookState.currentWord += pageWords;
// Add Read Event
this.bookState.readEvents.push({ startingWord, pageWords, elapsedTime });
// Render Next Page
await this.rendition.next();
// Reset Read Timer
this.bookState.pageStart = Date.now();
// Update Stats
let stats = this.getBookStats();
this.updateBookStats(stats);
// Test Position
console.log(await this.toPosition());
}
/**
* Progresses to the previous page & monitors reading activity
**/
async prevPage() {
// Render Previous Page
await this.rendition.prev();
// Update Current Word
let pageWords = await this.getVisibleWordCount();
this.bookState.currentWord -= pageWords;
// Reset Read Timer
this.bookState.pageStart = Date.now();
// Update Stats
let stats = this.getBookStats();
this.updateBookStats(stats);
// Test Position
console.log(await this.toPosition());
}
/**
* Derive chapter current page and total pages
**/
sectionProgress() {
let visibleItems = this.rendition.manager.visible();
if (visibleItems.length == 0) return console.log("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
**/
getBookStats() {
let currentProgress = this.sectionProgress();
if (!currentProgress) return;
let { sectionPages, sectionCurrentPage } = currentProgress;
let currentLocation = this.rendition.currentLocation();
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(
(this.bookState.currentWord / this.bookState.words) * 10000
) / 100,
};
}
/**
* Update elements with stats
**/
updateBookStats(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}`;
// Do Update
// console.log(data);
}
/**
* Get XPath from current location
**/
async toPosition() {
// Get DocFragment (current book spline index)
let currentPos = await this.rendition.currentLocation();
let docFragmentIndex = currentPos.start.index + 1;
// Base Position
let newPos = "/body/DocFragment[" + docFragmentIndex + "]/body";
// Get first visible node
let contents = this.rendition.getContents()[0];
let currentNode = contents.range(currentPos.start.cfi).startContainer
.parentNode;
// Walk upwards and build position until body
let childPos = "";
while (currentNode.nodeName != "BODY") {
let relativeIndex =
Array.from(currentNode.parentNode.children)
.filter((item) => item.nodeName == currentNode.nodeName)
.indexOf(currentNode) + 1;
// E.g: /div[10]
let itemPos =
"/" + currentNode.nodeName.toLowerCase() + "[" + relativeIndex + "]";
// Prepend childPos & Update currentNode refernce
childPos = itemPos + childPos;
currentNode = currentNode.parentNode;
}
// Return derived position
return newPos + childPos;
}
/**
* Get CFI from XPath
**/
async fromPosition(position) {
// Position 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 Position
if (!position || position == "") return;
// Match Document Fragment Index
let fragMatch = position.match(/^\/body\/DocFragment\[(\d+)\]/);
if (!fragMatch) {
console.warn("No Position Match");
return;
}
// Match Item Index
let indexMatch = position.match(/\.(\d+)$/);
let itemIndex = indexMatch ? parseInt(indexMatch[1]) : 0;
// Get Spine Item
let spinePosition = parseInt(fragMatch[1]) - 1;
let docItem = this.book.spine.get(spinePosition);
// Required for docItem.document Access
await docItem.load(this.book.load.bind(this.book));
// Derive XPath & Namespace
let namespaceURI = docItem.document.documentElement.namespaceURI;
let remainingXPath = position
// Replace with new base
.replace(fragMatch[0], "/html")
// Replace `.0` Ending Indexes
.replace(/\.(\d+)$/, "")
// Remove potential trailing `text()`
.replace(/\/text\(\)$/, "");
// Validate Namespace
if (namespaceURI) remainingXPath = remainingXPath.replaceAll("/", "/ns:");
// Perform XPath
let docSearch = docItem.document.evaluate(
remainingXPath,
docItem.document,
function (prefix) {
if (prefix === "ns") {
return namespaceURI;
} else {
return null;
}
}
);
// Get Element & CFI
let matchedItem = docSearch.iterateNext();
let matchedCFI = docItem.cfiFromElement(matchedItem);
return matchedCFI;
}
/**
* Get visible word count - used for reading stats
**/
async getVisibleWordCount() {
// 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.trim().split(/\s+/).length;
}
/**
* 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) +
")"
);
}
/**
* Save settings to localStorage
**/
saveSettings(obj) {
if (!this.readerSettings) this.loadSettings();
let newSettings = Object.assign(this.readerSettings, obj);
localStorage.setItem("readerSettings", JSON.stringify(newSettings));
}
/**
* Load reader settings from localStorage
**/
loadSettings() {
this.readerSettings = JSON.parse(
localStorage.getItem("readerSettings") || "{}"
);
}
}