657 lines
18 KiB
JavaScript
657 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);
|
|
if (!currentCFI) this.bookState.currentWord = 0;
|
|
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") || "{}"
|
|
);
|
|
}
|
|
}
|