mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
563 lines
17 KiB
HTML
563 lines
17 KiB
HTML
<!DOCTYPE html>
|
||
<!--Copyright (c) 2013, FuturePress
|
||
|
||
All rights reserved.
|
||
|
||
Redistribution and use in source and binary forms, with or without
|
||
modification, are permitted provided that the following conditions are met:
|
||
|
||
1. Redistributions of source code must retain the above copyright notice, this
|
||
list of conditions and the following disclaimer.
|
||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||
this list of conditions and the following disclaimer in the documentation
|
||
and/or other materials provided with the distribution.
|
||
|
||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||
|
||
The views and conclusions contained in the software and documentation are those
|
||
of the authors and should not be interpreted as representing official policies,
|
||
either expressed or implied, of the FreeBSD Project. -->
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
|
||
<script src="/epub/jszip.min.js"></script>
|
||
<script src="/epub/epub.js"></script>
|
||
</head>
|
||
<body>
|
||
|
||
<style>
|
||
body {
|
||
margin: 0;
|
||
background: #fafafa;
|
||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||
color: #333;
|
||
|
||
position: absolute;
|
||
height: 100%;
|
||
width: 100%;
|
||
}
|
||
|
||
#title {
|
||
width: 900px;
|
||
min-height: 18px;
|
||
margin: 10px auto;
|
||
text-align: center;
|
||
font-size: 16px;
|
||
color: #E2E2E2;
|
||
font-weight: 400;
|
||
}
|
||
|
||
#title:hover {
|
||
color: #777;
|
||
}
|
||
|
||
#viewer {
|
||
width: 80%;
|
||
height: 90%;
|
||
box-shadow: 0 0 4px #ccc;
|
||
padding: 10px 10px 0px 10px;
|
||
margin: 5px auto;
|
||
background: white;
|
||
}
|
||
|
||
#prev {
|
||
left: 0;
|
||
}
|
||
|
||
#next {
|
||
right: 0;
|
||
}
|
||
|
||
#toc {
|
||
display: block;
|
||
margin: 10px auto;
|
||
}
|
||
|
||
.arrow {
|
||
position: fixed;
|
||
top: 50%;
|
||
margin-top: -32px;
|
||
font-size: 64px;
|
||
color: #E2E2E2;
|
||
font-family: arial, sans-serif;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
-webkit-user-select: none;
|
||
-moz-user-select: none;
|
||
user-select: none;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.arrow:hover {
|
||
color: #777;
|
||
}
|
||
|
||
.arrow:active {
|
||
color: #000;
|
||
}
|
||
|
||
#book-viewer iframe {
|
||
padding: 40px 40px;
|
||
}
|
||
|
||
#controls > input[type=range] {
|
||
width: 400px;
|
||
}
|
||
|
||
#navigation h1 {
|
||
width: 200px;
|
||
font-size: 16px;
|
||
font-weight: normal;
|
||
color: #fff;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
#navigation h2 {
|
||
font-size: 14px;
|
||
font-weight: normal;
|
||
color: #B0B0B0;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
#navigation ul {
|
||
padding-left: 36px;
|
||
margin-left: 0;
|
||
margin-top: 12px;
|
||
margin-bottom: 12px;
|
||
width: 340px;
|
||
}
|
||
|
||
#navigation ul li {
|
||
list-style: decimal;
|
||
margin-bottom: 10px;
|
||
color: #cccddd;
|
||
font-size: 12px;
|
||
padding-left: 0;
|
||
margin-left: 0;
|
||
}
|
||
|
||
#navigation ul li a {
|
||
color: #ccc;
|
||
text-decoration: none;
|
||
}
|
||
|
||
#navigation ul li a:hover {
|
||
color: #fff;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
#navigation ul li {
|
||
color: #fff;
|
||
}
|
||
|
||
svg {
|
||
display: block;
|
||
}
|
||
|
||
#toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin: 8px auto;
|
||
padding: 0 12px;
|
||
flex-wrap: wrap;
|
||
font-size: 13px;
|
||
color: #444;
|
||
}
|
||
|
||
#toolbar #toc {
|
||
margin: 0;
|
||
}
|
||
|
||
#toolbar form {
|
||
display: flex;
|
||
gap: 4px;
|
||
align-items: center;
|
||
margin: 0;
|
||
}
|
||
|
||
#toolbar input[type="search"] {
|
||
padding: 4px 6px;
|
||
font-size: 13px;
|
||
border: 1px solid #ccc;
|
||
border-radius: 3px;
|
||
min-width: 180px;
|
||
}
|
||
|
||
#toolbar button {
|
||
padding: 3px 10px;
|
||
font-size: 13px;
|
||
border: 1px solid #ccc;
|
||
background: #f6f6f6;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
#toolbar button:disabled {
|
||
opacity: 0.45;
|
||
cursor: default;
|
||
}
|
||
|
||
#search-status {
|
||
color: #666;
|
||
min-width: 96px;
|
||
}
|
||
|
||
#search-status.error {
|
||
color: #b00;
|
||
}
|
||
|
||
</style>
|
||
|
||
<div id="toolbar">
|
||
<select id="toc"></select>
|
||
<form id="search-form" onsubmit="return false;">
|
||
<input id="search-input" type="search" placeholder="Search in book…" aria-label="Search in book"/>
|
||
<button id="search-submit" type="submit">Search</button>
|
||
<button id="search-prev" type="button" title="Previous match" disabled>‹</button>
|
||
<button id="search-next" type="button" title="Next match" disabled>›</button>
|
||
<button id="search-clear" type="button" title="Clear search" disabled>×</button>
|
||
</form>
|
||
<span id="search-status"></span>
|
||
</div>
|
||
<div id="viewer"></div>
|
||
<a id="prev" href="#prev" class="arrow">‹</a>
|
||
<a id="next" href="#next" class="arrow">›</a>
|
||
|
||
<script>
|
||
let params = URLSearchParams && new URLSearchParams(document.location.search.substring(1));
|
||
let url = params && params.get("url") && decodeURIComponent(params.get("url"));
|
||
let currentSectionIndex = (params && params.get("loc")) ? params.get("loc") : undefined;
|
||
// Optional search term passed in from a search-result deep link. If present we'll
|
||
// search the spine item at `loc` and jump to + highlight the first match.
|
||
let searchQuery = (params && params.get("q")) ? params.get("q") : null;
|
||
|
||
let book = ePub(url);
|
||
let rendition = book.renderTo("viewer", {
|
||
flow: "paginated",
|
||
width: "100%",
|
||
height: "100%"
|
||
});
|
||
|
||
rendition.display(currentSectionIndex);
|
||
|
||
// --- In-book search ---------------------------------------------------
|
||
// State is module-level so the toolbar buttons can drive it. `hits` is the
|
||
// most recent search's results in spine order; `currentHitIndex` tracks
|
||
// which one is "selected" for prev/next navigation.
|
||
let hits = [];
|
||
let currentHitIndex = -1;
|
||
let searchInProgress = false;
|
||
const SEARCH_CONCURRENCY = 8;
|
||
|
||
const $input = document.getElementById("search-input");
|
||
const $submit = document.getElementById("search-submit");
|
||
const $prev = document.getElementById("search-prev");
|
||
const $next = document.getElementById("search-next");
|
||
const $clear = document.getElementById("search-clear");
|
||
const $status = document.getElementById("search-status");
|
||
const $form = document.getElementById("search-form");
|
||
|
||
function setStatus(text, isError) {
|
||
$status.textContent = text || "";
|
||
$status.classList.toggle("error", !!isError);
|
||
}
|
||
|
||
function updateButtons() {
|
||
const hasHits = hits.length > 0;
|
||
$prev.disabled = !hasHits || searchInProgress;
|
||
$next.disabled = !hasHits || searchInProgress;
|
||
$clear.disabled = (!hasHits && !$input.value) || searchInProgress;
|
||
$submit.disabled = searchInProgress;
|
||
}
|
||
|
||
function clearHighlights() {
|
||
for (const hit of hits) {
|
||
try {
|
||
rendition.annotations.remove(hit.cfi, "highlight");
|
||
} catch (e) { /* already gone — ignore */ }
|
||
}
|
||
}
|
||
|
||
function clearSearch() {
|
||
clearHighlights();
|
||
hits = [];
|
||
currentHitIndex = -1;
|
||
setStatus("");
|
||
updateButtons();
|
||
}
|
||
|
||
function gotoHit(i) {
|
||
if (!hits.length) return;
|
||
// Wrap around both directions for easy cycling.
|
||
if (i < 0) i = hits.length - 1;
|
||
if (i >= hits.length) i = 0;
|
||
currentHitIndex = i;
|
||
try {
|
||
rendition.display(hits[i].cfi);
|
||
} catch (e) {
|
||
console.warn("failed to display hit", e);
|
||
}
|
||
setStatus(`${i + 1} of ${hits.length}`);
|
||
}
|
||
|
||
// Run a search across every spine item, collecting CFIs and excerpts.
|
||
// Uses small-chunk concurrency so a 600-item book doesn't serialize one
|
||
// load at a time and doesn't stampede memory by loading all at once.
|
||
// `preferredSpineIndex` biases the initial hit selection. When a search is
|
||
// kicked off from a `?loc=…&q=…` deep-link, callers pass the loc so the
|
||
// viewer lands on the match in that chapter rather than jumping to the
|
||
// first match in the whole book.
|
||
async function runBookSearch(query, preferredSpineIndex) {
|
||
query = (query || "").trim();
|
||
if (!query) {
|
||
clearSearch();
|
||
return;
|
||
}
|
||
// Always clear prior state before starting a new search.
|
||
clearHighlights();
|
||
hits = [];
|
||
currentHitIndex = -1;
|
||
searchInProgress = true;
|
||
updateButtons();
|
||
setStatus("Searching…");
|
||
|
||
const spineItems = (book.spine && book.spine.spineItems) || [];
|
||
const total = spineItems.length;
|
||
let processed = 0;
|
||
const newHits = [];
|
||
|
||
async function searchOne(item) {
|
||
try {
|
||
await Promise.resolve(item.load(book.load.bind(book)));
|
||
const results = item.find(query) || [];
|
||
for (const r of results) {
|
||
newHits.push({
|
||
spineIndex: item.index,
|
||
cfi: r.cfi,
|
||
excerpt: r.excerpt,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn("search failed for item", item && item.href, e);
|
||
} finally {
|
||
try { item.unload(); } catch (e) { /* ignore */ }
|
||
processed += 1;
|
||
// Live progress — users with big books want to know something's happening.
|
||
if (processed % SEARCH_CONCURRENCY === 0 || processed === total) {
|
||
setStatus(`Searching… ${processed} / ${total}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Simple fixed-concurrency pool.
|
||
let cursor = 0;
|
||
async function worker() {
|
||
while (cursor < spineItems.length) {
|
||
const i = cursor++;
|
||
await searchOne(spineItems[i]);
|
||
}
|
||
}
|
||
const workers = [];
|
||
for (let i = 0; i < Math.min(SEARCH_CONCURRENCY, spineItems.length); i++) {
|
||
workers.push(worker());
|
||
}
|
||
await Promise.all(workers);
|
||
|
||
// Sort hits by spine order then position within the section. `spineIndex`
|
||
// handles the first; CFI string comparison is a reasonable tiebreaker.
|
||
newHits.sort((a, b) => {
|
||
if (a.spineIndex !== b.spineIndex) return a.spineIndex - b.spineIndex;
|
||
return a.cfi < b.cfi ? -1 : a.cfi > b.cfi ? 1 : 0;
|
||
});
|
||
|
||
// Highlight each match. The annotation only renders visibly when its
|
||
// section is rendered, but calling this up-front means navigating to
|
||
// any hit arrives with the term already marked.
|
||
for (const hit of newHits) {
|
||
try {
|
||
rendition.annotations.highlight(hit.cfi);
|
||
} catch (e) { /* dup or invalid CFI — ignore */ }
|
||
}
|
||
|
||
hits = newHits;
|
||
searchInProgress = false;
|
||
|
||
if (hits.length === 0) {
|
||
setStatus("No matches");
|
||
} else {
|
||
// If the caller pinned a preferred spine item (e.g. a deep-link with
|
||
// `?loc=`), prefer the first hit inside that exact section. Fall back
|
||
// to the first hit globally only when there's no match in the pin.
|
||
let startIdx = 0;
|
||
const pin = (preferredSpineIndex === undefined || preferredSpineIndex === null)
|
||
? null
|
||
: parseInt(preferredSpineIndex, 10);
|
||
if (pin !== null && !isNaN(pin)) {
|
||
const pinned = hits.findIndex(h => h.spineIndex === pin);
|
||
if (pinned !== -1) startIdx = pinned;
|
||
}
|
||
gotoHit(startIdx);
|
||
}
|
||
updateButtons();
|
||
}
|
||
|
||
// Wire up the toolbar.
|
||
$form.addEventListener("submit", function (e) {
|
||
e.preventDefault();
|
||
runBookSearch($input.value);
|
||
});
|
||
$next.addEventListener("click", function () { gotoHit(currentHitIndex + 1); });
|
||
$prev.addEventListener("click", function () { gotoHit(currentHitIndex - 1); });
|
||
$clear.addEventListener("click", function () {
|
||
$input.value = "";
|
||
clearSearch();
|
||
});
|
||
$input.addEventListener("input", function () {
|
||
// Enable/disable the clear button as the user types, even with no hits yet.
|
||
updateButtons();
|
||
});
|
||
updateButtons();
|
||
|
||
// Deep-link `q=` — pre-fill the box and run the full search once the book
|
||
// is ready. Pass `loc` (when present) as the preferred spine index so the
|
||
// viewer lands on the match inside that chapter rather than the first match
|
||
// in the book. Manual searches triggered from the toolbar don't pass a pin.
|
||
if (searchQuery) {
|
||
$input.value = searchQuery;
|
||
book.ready.then(function () {
|
||
runBookSearch(searchQuery, currentSectionIndex);
|
||
});
|
||
}
|
||
|
||
book.ready.then(() => {
|
||
|
||
let next = document.getElementById("next");
|
||
|
||
next.addEventListener("click", function (e) {
|
||
book.package.metadata.direction === "rtl" ? rendition.prev() : rendition.next();
|
||
e.preventDefault();
|
||
}, false);
|
||
|
||
let prev = document.getElementById("prev");
|
||
prev.addEventListener("click", function (e) {
|
||
book.package.metadata.direction === "rtl" ? rendition.next() : rendition.prev();
|
||
e.preventDefault();
|
||
}, false);
|
||
|
||
let keyListener = function (e) {
|
||
|
||
// Left Key
|
||
if ((e.keyCode || e.which) === 37) {
|
||
book.package.metadata.direction === "rtl" ? rendition.next() : rendition.prev();
|
||
}
|
||
|
||
// Right Key
|
||
if ((e.keyCode || e.which) === 39) {
|
||
book.package.metadata.direction === "rtl" ? rendition.prev() : rendition.next();
|
||
}
|
||
|
||
};
|
||
|
||
rendition.on("keyup", keyListener);
|
||
document.addEventListener("keyup", keyListener, false);
|
||
|
||
})
|
||
|
||
let title = document.getElementById("title");
|
||
|
||
rendition.on("rendered", function (section) {
|
||
let current = book.navigation && book.navigation.get(section.href);
|
||
|
||
if (current) {
|
||
let $select = document.getElementById("toc");
|
||
let $selected = $select.querySelector("option[selected]");
|
||
if ($selected) {
|
||
$selected.removeAttribute("selected");
|
||
}
|
||
|
||
let $options = $select.querySelectorAll("option");
|
||
for (let i = 0; i < $options.length; ++i) {
|
||
let selected = $options[i].getAttribute("ref") === current.href;
|
||
if (selected) {
|
||
$options[i].setAttribute("selected", "");
|
||
}
|
||
}
|
||
}
|
||
|
||
});
|
||
|
||
rendition.on("relocated", function (location) {
|
||
console.log(location);
|
||
|
||
let next = book.package.metadata.direction === "rtl" ? document.getElementById("prev") : document.getElementById("next");
|
||
let prev = book.package.metadata.direction === "rtl" ? document.getElementById("next") : document.getElementById("prev");
|
||
|
||
if (location.atEnd) {
|
||
next.style.visibility = "hidden";
|
||
} else {
|
||
next.style.visibility = "visible";
|
||
}
|
||
|
||
if (location.atStart) {
|
||
prev.style.visibility = "hidden";
|
||
} else {
|
||
prev.style.visibility = "visible";
|
||
}
|
||
|
||
});
|
||
|
||
rendition.on("layout", function (layout) {
|
||
let viewer = document.getElementById("viewer");
|
||
|
||
if (layout.spread) {
|
||
viewer.classList.remove('single');
|
||
} else {
|
||
viewer.classList.add('single');
|
||
}
|
||
});
|
||
|
||
window.addEventListener("unload", function () {
|
||
console.log("unloading");
|
||
this.book.destroy();
|
||
});
|
||
|
||
book.loaded.navigation.then(function (toc) {
|
||
let $select = document.getElementById("toc"),
|
||
docfrag = document.createDocumentFragment();
|
||
|
||
toc.forEach(function (chapter) {
|
||
let option = document.createElement("option");
|
||
option.textContent = chapter.label;
|
||
option.setAttribute("ref", chapter.href);
|
||
|
||
docfrag.appendChild(option);
|
||
});
|
||
|
||
$select.appendChild(docfrag);
|
||
|
||
$select.onchange = function () {
|
||
let index = $select.selectedIndex,
|
||
url = $select.options[index].getAttribute("ref");
|
||
rendition.display(url);
|
||
return false;
|
||
};
|
||
|
||
});
|
||
|
||
</script>
|
||
|
||
</body>
|
||
|
||
</html>
|