969 lines
27 KiB
JavaScript
969 lines
27 KiB
JavaScript
// Copyright (C) 2004-2022 Artifex Software, Inc.
|
||
//
|
||
// This file is part of MuPDF.
|
||
//
|
||
// MuPDF is free software: you can redistribute it and/or modify it under the
|
||
// terms of the GNU Affero General Public License as published by the Free
|
||
// Software Foundation, either version 3 of the License, or (at your option)
|
||
// any later version.
|
||
//
|
||
// MuPDF is distributed in the hope that it will be useful, but WITHOUT ANY
|
||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||
// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||
// details.
|
||
//
|
||
// You should have received a copy of the GNU Affero General Public License
|
||
// along with MuPDF. If not, see <https://www.gnu.org/licenses/agpl-3.0.en.html>
|
||
//
|
||
// Alternative licensing terms are available from the licensor.
|
||
// For commercial licensing, see <https://www.artifex.com/> or contact
|
||
// Artifex Software, Inc., 1305 Grant Avenue - Suite 200, Novato,
|
||
// CA 94945, U.S.A., +1(415)492-9861, for further information.
|
||
|
||
/* eslint-disable no-unused-vars */
|
||
|
||
class MupdfPageViewer {
|
||
constructor(worker, pageNumber, defaultSize, dpi, title) {
|
||
this.title = title
|
||
this.worker = worker
|
||
this.pageNumber = pageNumber
|
||
this.size = defaultSize
|
||
this.sizeIsDefault = true
|
||
|
||
const rootNode = document.createElement("div")
|
||
rootNode.classList.add("page")
|
||
|
||
const canvasNode = document.createElement("canvas")
|
||
rootNode.appendChild(canvasNode)
|
||
|
||
const anchor = document.createElement("a")
|
||
anchor.classList.add("anchor")
|
||
// TODO - document the "+ 1" better
|
||
anchor.id = "page" + (pageNumber + 1)
|
||
rootNode.appendChild(anchor)
|
||
rootNode.pageNumber = pageNumber
|
||
|
||
this.rootNode = rootNode
|
||
this.canvasNode = canvasNode
|
||
this.canvasCtx = canvasNode.getContext("2d")
|
||
this._updateSize(dpi)
|
||
|
||
this.renderPromise = null
|
||
this.queuedRenderArgs = null
|
||
|
||
this.textNode = null
|
||
this.textPromise = null
|
||
this.textResultObject = null
|
||
|
||
this.linksNode = null
|
||
this.linksPromise = null
|
||
this.linksResultObject = null
|
||
|
||
this.searchHitsNode = null
|
||
this.searchPromise = null
|
||
this.searchResultObject = null
|
||
this.lastSearchNeedle = null
|
||
this.searchNeedle = null
|
||
}
|
||
|
||
// TODO - move searchNeedle out
|
||
render(dpi, searchNeedle) {
|
||
// TODO - error handling
|
||
this._loadPageImg({ dpi })
|
||
this._loadPageText(dpi)
|
||
this._loadPageLinks(dpi)
|
||
this._loadPageSearch(dpi, searchNeedle)
|
||
}
|
||
|
||
// TODO - update child nodes
|
||
setZoom(zoomLevel) {
|
||
const dpi = ((zoomLevel * 96) / 100) | 0
|
||
|
||
this._updateSize(dpi)
|
||
}
|
||
|
||
setSearchNeedle(searchNeedle = null) {
|
||
this.searchNeedle = searchNeedle
|
||
}
|
||
|
||
clear() {
|
||
this.textNode?.remove()
|
||
this.linksNode?.remove()
|
||
this.searchHitsNode?.remove()
|
||
|
||
// TODO - use promise cancelling
|
||
this.renderPromise = null
|
||
this.textPromise = null
|
||
this.linksPromise = null
|
||
this.searchPromise = null
|
||
|
||
this.renderPromise = null
|
||
this.queuedRenderArgs = null
|
||
|
||
this.textNode = null
|
||
this.textPromise = null
|
||
this.textResultObject = null
|
||
|
||
this.linksNode = null
|
||
this.linksPromise = null
|
||
this.linksResultObject = null
|
||
|
||
this.searchHitsNode = null
|
||
this.searchPromise = null
|
||
this.searchResultObject = null
|
||
this.lastSearchNeedle = null
|
||
this.searchNeedle = null
|
||
|
||
this.mouseIsPressed = false
|
||
}
|
||
|
||
// TODO - this is destructive and makes other method get null ref errors
|
||
showError(functionName, error) {
|
||
console.error(`mupdf.${functionName}: ${error.message}:\n${error.stack}`)
|
||
|
||
let div = document.createElement("div")
|
||
div.classList.add("error")
|
||
div.textContent = error.name + ": " + error.message
|
||
//this.clear()
|
||
this.rootNode.replaceChildren(div)
|
||
}
|
||
|
||
async mouseDown(event, dpi) {
|
||
let { x, y } = this._getLocalCoords(event.clientX, event.clientY)
|
||
// TODO - remove "+ 1"
|
||
let changed = await this.worker.mouseDownOnPage(this.pageNumber + 1, dpi * devicePixelRatio, x, y)
|
||
this.mouseIsPressed = true
|
||
if (changed) {
|
||
this._invalidatePageImg()
|
||
this._loadPageImg({ dpi })
|
||
}
|
||
}
|
||
|
||
async mouseMove(event, dpi) {
|
||
let { x, y } = this._getLocalCoords(event.clientX, event.clientY)
|
||
let changed
|
||
// TODO - handle multiple buttons
|
||
// see https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
|
||
if (this.mouseIsPressed) {
|
||
if (event.buttons == 0) {
|
||
// In case we missed an onmouseup event outside of the frame
|
||
this.mouseIsPressed = false
|
||
// TODO - remove "+ 1"
|
||
changed = await this.worker.mouseUpOnPage(this.pageNumber + 1, dpi * devicePixelRatio, x, y)
|
||
} else {
|
||
// TODO - remove "+ 1"
|
||
changed = await this.worker.mouseDragOnPage(this.pageNumber + 1, dpi * devicePixelRatio, x, y)
|
||
}
|
||
} else {
|
||
// TODO - remove "+ 1"
|
||
changed = await this.worker.mouseMoveOnPage(this.pageNumber + 1, dpi * devicePixelRatio, x, y)
|
||
}
|
||
if (changed) {
|
||
this._invalidatePageImg()
|
||
this._loadPageImg({ dpi })
|
||
}
|
||
}
|
||
|
||
async mouseUp(event, dpi) {
|
||
let { x, y } = this._getLocalCoords(event.clientX, event.clientY)
|
||
this.mouseIsPressed = false
|
||
// TODO - remove "+ 1"
|
||
let changed = await this.worker.mouseUpOnPage(this.pageNumber + 1, dpi * devicePixelRatio, x, y)
|
||
if (changed) {
|
||
this._invalidatePageImg()
|
||
this._loadPageImg({ dpi })
|
||
}
|
||
}
|
||
|
||
// --- INTERNAL METHODS ---
|
||
|
||
// TODO - remove dpi param
|
||
_updateSize(dpi) {
|
||
// We use the `foo | 0` notation to convert dimensions to integers.
|
||
// This matches the conversion done in `mupdf.js` when `Pixmap.withBbox`
|
||
// calls `libmupdf._wasm_new_pixmap_with_bbox`.
|
||
this.rootNode.style.width = (((this.size.width * dpi) / 72) | 0) + "px"
|
||
this.rootNode.style.height = (((this.size.height * dpi) / 72) | 0) + "px"
|
||
this.canvasNode.style.width = (((this.size.width * dpi) / 72) | 0) + "px"
|
||
this.canvasNode.style.height = (((this.size.height * dpi) / 72) | 0) + "px"
|
||
}
|
||
|
||
async _loadPageImg(renderArgs) {
|
||
if (this.renderPromise != null || this.renderIsOngoing) {
|
||
// If a render is ongoing, we mark the current arguments as queued
|
||
// to be processed when the render ends.
|
||
// This also erases any previous queued render arguments.
|
||
this.queuedRenderArgs = renderArgs
|
||
return
|
||
}
|
||
if (this.canvasNode?.renderArgs != null) {
|
||
// If the current image node was rendered with the same arguments
|
||
// we skip the render.
|
||
if (renderArgs.dpi === this.canvasNode.renderArgs.dpi)
|
||
return
|
||
}
|
||
|
||
let { dpi } = renderArgs
|
||
|
||
try {
|
||
// FIXME - find better system for skipping duplicate renders
|
||
this.renderIsOngoing = true
|
||
|
||
if (this.sizeIsDefault) {
|
||
// TODO - remove "+ 1"
|
||
this.size = await this.worker.getPageSize(this.pageNumber + 1)
|
||
this.sizeIsDefault = false
|
||
this._updateSize(dpi)
|
||
}
|
||
// TODO - remove "+ 1"
|
||
this.renderPromise = this.worker.drawPageAsPixmap(this.pageNumber + 1, dpi * devicePixelRatio)
|
||
let imageData = await this.renderPromise
|
||
|
||
// if render was aborted, return early
|
||
if (imageData == null)
|
||
return
|
||
|
||
this.canvasNode.renderArgs = renderArgs
|
||
this.canvasNode.width = imageData.width
|
||
this.canvasNode.height = imageData.height
|
||
this.canvasCtx.putImageData(imageData, 0, 0)
|
||
|
||
this.canvasCtx.font = "2em serif"
|
||
this.canvasCtx.fillStyle = "#E0CACA";
|
||
this.canvasCtx.fillText(""+(this.pageNumber + 1), 1, 25);
|
||
|
||
|
||
|
||
|
||
} catch (error) {
|
||
this.showError("_loadPageImg", error)
|
||
} finally {
|
||
this.renderPromise = null
|
||
this.renderIsOngoing = false
|
||
}
|
||
|
||
if (this.queuedRenderArgs != null) {
|
||
// TODO - Error handling
|
||
this._loadPageImg(this.queuedRenderArgs)
|
||
this.queuedRenderArgs = null
|
||
}
|
||
}
|
||
|
||
_invalidatePageImg() {
|
||
if (this.canvasNode)
|
||
this.canvasNode.renderArgs = null
|
||
}
|
||
|
||
// TODO - replace "dpi" with "scale"?
|
||
async _loadPageText(dpi) {
|
||
// TODO - Disable text when editing (conditions to be figured out)
|
||
if (this.textNode != null && dpi === this.textNode.dpi) {
|
||
// Text was already rendered at the right scale, nothing to be done
|
||
return
|
||
}
|
||
if (this.textResultObject) {
|
||
// Text was already returned, just needs to be rescaled
|
||
this._applyPageText(this.textResultObject, dpi)
|
||
return
|
||
}
|
||
|
||
let textNode = document.createElement("div")
|
||
textNode.classList.add("text")
|
||
|
||
this.textNode?.remove()
|
||
this.textNode = textNode
|
||
this.rootNode.appendChild(textNode)
|
||
|
||
try {
|
||
// TODO - remove "+ 1"
|
||
this.textPromise = this.worker.getPageText(this.pageNumber + 1)
|
||
|
||
this.textResultObject = await this.textPromise
|
||
this._applyPageText(this.textResultObject, dpi)
|
||
} catch (error) {
|
||
this.showError("_loadPageText", error)
|
||
} finally {
|
||
this.textPromise = null
|
||
}
|
||
}
|
||
|
||
_applyPageText(textResultObject, dpi) {
|
||
this.textNode.dpi = dpi
|
||
let nodes = []
|
||
let pdf_w = []
|
||
let html_w = []
|
||
let text_len = []
|
||
let scale = dpi / 72
|
||
this.textNode.replaceChildren()
|
||
for (let block of textResultObject.blocks) {
|
||
if (block.type === "text") {
|
||
for (let line of block.lines) {
|
||
let text = document.createElement("span")
|
||
text.style.left = line.bbox.x * scale + "px"
|
||
text.style.top = (line.y - line.font.size * 0.8) * scale + "px"
|
||
text.style.height = line.bbox.h * scale + "px"
|
||
text.style.fontSize = line.font.size * scale + "px"
|
||
text.style.fontFamily = line.font.family
|
||
text.style.fontWeight = line.font.weight
|
||
text.style.fontStyle = line.font.style
|
||
text.textContent = line.text
|
||
this.textNode.appendChild(text)
|
||
nodes.push(text)
|
||
pdf_w.push(line.bbox.w * scale)
|
||
text_len.push(line.text.length - 1)
|
||
}
|
||
}
|
||
}
|
||
for (let i = 0; i < nodes.length; ++i) {
|
||
if (text_len[i] > 0)
|
||
html_w[i] = nodes[i].clientWidth
|
||
}
|
||
for (let i = 0; i < nodes.length; ++i) {
|
||
if (text_len[i] > 0)
|
||
nodes[i].style.letterSpacing = (pdf_w[i] - html_w[i]) / text_len[i] + "px"
|
||
}
|
||
}
|
||
|
||
async _loadPageLinks(dpi) {
|
||
if (this.linksNode != null && dpi === this.linksNode.dpi) {
|
||
// Links were already rendered at the right scale, nothing to be done
|
||
return
|
||
}
|
||
if (this.linksResultObject) {
|
||
// Links were already returned, just need to be rescaled
|
||
this._applyPageLinks(this.linksResultObject, dpi)
|
||
return
|
||
}
|
||
|
||
let linksNode = document.createElement("div")
|
||
linksNode.classList.add("links")
|
||
|
||
// TODO - Figure out node order
|
||
this.linksNode?.remove()
|
||
this.linksNode = linksNode
|
||
this.rootNode.appendChild(linksNode)
|
||
|
||
try {
|
||
// TODO - remove "+ 1"
|
||
this.linksPromise = this.worker.getPageLinks(this.pageNumber + 1)
|
||
|
||
this.linksResultObject = await this.linksPromise
|
||
this._applyPageLinks(this.linksResultObject, dpi)
|
||
} catch (error) {
|
||
this.showError("_loadPageLinks", error)
|
||
} finally {
|
||
this.linksPromise = null
|
||
}
|
||
}
|
||
|
||
_applyPageLinks(linksResultObject, dpi) {
|
||
let scale = dpi / 72
|
||
this.linksNode.dpi = dpi
|
||
this.linksNode.replaceChildren()
|
||
for (let link of linksResultObject) {
|
||
let a = document.createElement("a")
|
||
a.href = link.href
|
||
a.style.left = link.x * scale + "px"
|
||
a.style.top = link.y * scale + "px"
|
||
a.style.width = link.w * scale + "px"
|
||
a.style.height = link.h * scale + "px"
|
||
this.linksNode.appendChild(a)
|
||
}
|
||
}
|
||
|
||
async _loadPageSearch(dpi, searchNeedle) {
|
||
if (
|
||
this.searchHitsNode != null &&
|
||
dpi === this.searchHitsNode.dpi &&
|
||
searchNeedle == this.searchHitsNode.searchNeedle
|
||
) {
|
||
// Search results were already rendered at the right scale, nothing to be done
|
||
return
|
||
}
|
||
if (this.searchResultObject && searchNeedle == this.searchHitsNode.searchNeedle) {
|
||
// Search results were already returned, just need to be rescaled
|
||
this._applyPageSearch(this.searchResultObject, dpi)
|
||
return
|
||
}
|
||
|
||
// TODO - cancel previous load
|
||
|
||
let searchHitsNode = document.createElement("div")
|
||
searchHitsNode.classList.add("searchHitList")
|
||
this.searchHitsNode?.remove()
|
||
this.searchHitsNode = searchHitsNode
|
||
this.rootNode.appendChild(searchHitsNode)
|
||
|
||
this.searchNeedle = searchNeedle ?? ""
|
||
|
||
try {
|
||
if (this.searchNeedle !== "") {
|
||
// TODO - remove "+ 1"
|
||
console.log("SEARCH", this.pageNumber + 1, JSON.stringify(this.searchNeedle))
|
||
this.searchPromise = this.worker.search(this.pageNumber + 1, this.searchNeedle)
|
||
this.searchResultObject = await this.searchPromise
|
||
} else {
|
||
this.searchResultObject = []
|
||
}
|
||
|
||
this._applyPageSearch(this.searchResultObject, searchNeedle, dpi)
|
||
} catch (error) {
|
||
this.showError("_loadPageSearch", error)
|
||
} finally {
|
||
this.searchPromise = null
|
||
}
|
||
}
|
||
|
||
_applyPageSearch(searchResultObject, searchNeedle, dpi) {
|
||
let scale = dpi / 72
|
||
this.searchHitsNode.searchNeedle = searchNeedle
|
||
this.searchHitsNode.dpi = dpi
|
||
this.searchHitsNode.replaceChildren()
|
||
for (let bbox of searchResultObject) {
|
||
let div = document.createElement("div")
|
||
div.classList.add("searchHit")
|
||
div.style.left = bbox.x * scale + "px"
|
||
div.style.top = bbox.y * scale + "px"
|
||
div.style.width = bbox.w * scale + "px"
|
||
div.style.height = bbox.h * scale + "px"
|
||
this.searchHitsNode.appendChild(div)
|
||
}
|
||
}
|
||
|
||
_getLocalCoords(clientX, clientY) {
|
||
const canvas = this.canvasNode
|
||
let x = clientX - canvas.getBoundingClientRect().left - canvas.clientLeft + canvas.scrollLeft
|
||
let y = clientY - canvas.getBoundingClientRect().top - canvas.clientTop + canvas.scrollTop
|
||
return { x, y }
|
||
}
|
||
}
|
||
|
||
let zoomLevels = [ 5,10, 25, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200 ]
|
||
|
||
// TODO - Split into separate file
|
||
class MupdfDocumentHandler {
|
||
constructor(documentUri, initialPage, showDefaultUi) {}
|
||
|
||
static async createHandler(mupdfWorker, viewerDivs) {
|
||
// TODO validate worker param
|
||
|
||
const handler = new MupdfDocumentHandler()
|
||
|
||
|
||
|
||
|
||
await mupdfWorker.layout(viewerDivs.documentWidth.value, viewerDivs.documentHeight.value, viewerDivs.documentFontSize.value)
|
||
|
||
const pageCount = await mupdfWorker.countPages()
|
||
const title = await mupdfWorker.documentTitle()
|
||
|
||
|
||
// Use second page as default page size (the cover page is often differently sized)
|
||
const defaultSize = await mupdfWorker.getPageSize(pageCount > 1 ? 2 : 1)
|
||
|
||
handler.mupdfWorker = mupdfWorker
|
||
handler.pageCount = pageCount
|
||
handler.title = title
|
||
handler.defaultSize = defaultSize
|
||
handler.searchNeedle = ""
|
||
|
||
handler.zoomLevel = 100
|
||
|
||
// TODO - Add a second observer with bigger margin to recycle old pages
|
||
handler.activePages = new Set()
|
||
handler.pageObserver = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
if (entry.isIntersecting) {
|
||
handler.activePages.add(entry.target)
|
||
} else {
|
||
handler.activePages.delete(entry.target)
|
||
}
|
||
}
|
||
},
|
||
{
|
||
// This means we have roughly five viewports of vertical "head start" where
|
||
// the page is rendered before it becomes visible
|
||
rootMargin: "500% 0px",
|
||
}
|
||
)
|
||
|
||
// TODO
|
||
// This is a hack to compensate for the lack of a priority queue
|
||
// We wait until the user has stopped scrolling to load pages.
|
||
let scrollTimer = null
|
||
handler.scrollListener = function (event) {
|
||
if (scrollTimer !== null)
|
||
clearTimeout(scrollTimer)
|
||
scrollTimer = setTimeout(() => {
|
||
scrollTimer = null
|
||
handler._updateView()
|
||
}, 50)
|
||
}
|
||
document.addEventListener("scroll", handler.scrollListener)
|
||
|
||
//const rootDiv = document.createElement("div")
|
||
|
||
handler.gridMenubarDiv = viewerDivs.gridMenubarDiv
|
||
handler.gridSidebarDiv = viewerDivs.gridSidebarDiv
|
||
handler.gridMainDiv = viewerDivs.gridMainDiv
|
||
handler.pagesDiv = viewerDivs.pagesDiv
|
||
handler.searchDialogDiv = viewerDivs.searchDialogDiv
|
||
handler.outlineNode = viewerDivs.outlineNode
|
||
handler.searchStatusDiv = viewerDivs.searchStatusDiv
|
||
|
||
const pagesDiv = viewerDivs.pagesDiv
|
||
|
||
let pages = new Array(pageCount)
|
||
for (let i = 0; i < pageCount; ++i) {
|
||
const page = new MupdfPageViewer(mupdfWorker, i, defaultSize, handler._dpi(), handler.title)
|
||
pages[i] = page
|
||
pagesDiv.appendChild(page.rootNode)
|
||
handler.pageObserver.observe(page.rootNode)
|
||
}
|
||
|
||
function isPage(element) {
|
||
return element.tagName === "CANVAS" && element.closest("div.page") != null
|
||
}
|
||
|
||
const searchDivInput = document.createElement("input")
|
||
searchDivInput.id = "search-text"
|
||
searchDivInput.type = "search"
|
||
searchDivInput.size = 20
|
||
|
||
// Adjust size for mobile devices
|
||
if (window.innerWidth <= 480) {
|
||
searchDivInput.size = 10
|
||
}
|
||
searchDivInput.addEventListener("input", () => {
|
||
let newNeedle = searchDivInput.value ?? ""
|
||
handler.setSearch(newNeedle)
|
||
})
|
||
searchDivInput.addEventListener("keydown", (event) => {
|
||
if (event.key == "Enter")
|
||
handler.runSearch(event.shiftKey ? -1 : 1)
|
||
})
|
||
const searchStatusDiv = document.createElement("div")
|
||
searchStatusDiv.id = "search-status"
|
||
searchStatusDiv.innerText = "-"
|
||
|
||
// Create search dialog header
|
||
const searchHeader = document.createElement("div")
|
||
searchHeader.classList.add("dialog-header")
|
||
|
||
const searchTitle = document.createElement("h3")
|
||
searchTitle.classList.add("dialog-title")
|
||
searchTitle.textContent = "Search"
|
||
|
||
const dialogCloseButton = document.createElement("button")
|
||
dialogCloseButton.classList.add("sidebar-close")
|
||
dialogCloseButton.innerHTML = "×"
|
||
dialogCloseButton.title = "Close search"
|
||
dialogCloseButton.addEventListener("click", () => handler.hideSearchBox())
|
||
|
||
searchHeader.append(searchTitle, dialogCloseButton)
|
||
|
||
const searchFlex = document.createElement("div")
|
||
searchFlex.classList = [ "flex" ]
|
||
const ltButton = document.createElement("button")
|
||
ltButton.classList.add("sidebar-close")
|
||
ltButton.innerText = "<"
|
||
ltButton.title = "Previous search result"
|
||
ltButton.addEventListener("click", () => handler.runSearch(-1))
|
||
const gtButton = document.createElement("button")
|
||
gtButton.classList.add("sidebar-close")
|
||
gtButton.innerText = ">"
|
||
gtButton.title = "Next search result"
|
||
gtButton.addEventListener("click", () => handler.runSearch(1))
|
||
|
||
searchFlex.append(searchDivInput, ltButton, gtButton)
|
||
|
||
handler.searchDialogDiv.append(searchHeader, searchFlex, searchStatusDiv)
|
||
|
||
handler.searchStatusDiv = searchStatusDiv
|
||
handler.searchDivInput = searchDivInput
|
||
handler.currentSearchPage = 1
|
||
|
||
// TODO use rootDiv instead
|
||
pagesDiv.addEventListener(
|
||
"wheel",
|
||
(event) => {
|
||
if (event.ctrlKey || event.metaKey) {
|
||
if (event.deltaY < 0)
|
||
handler.zoomIn()
|
||
else if (event.deltaY > 0)
|
||
handler.zoomOut()
|
||
event.preventDefault()
|
||
}
|
||
},
|
||
{ passive: false }
|
||
)
|
||
|
||
//handler.rootDiv = rootDiv
|
||
handler.pagesDiv = pagesDiv // TODO - rename
|
||
handler.pages = pages
|
||
|
||
// TODO - remove await
|
||
let outline = await mupdfWorker.documentOutline()
|
||
let outlineNode = viewerDivs.outlineNode
|
||
if (outline) {
|
||
handler._buildOutline(outlineNode, outline)
|
||
//handler.showOutline()
|
||
} else {
|
||
handler.hideOutline()
|
||
}
|
||
|
||
// TODO - remove once we add a priority queue
|
||
for (let i = 0; i < Math.min(pageCount, 5); ++i) {
|
||
handler.activePages.add(pages[i].rootNode)
|
||
}
|
||
|
||
handler._updateView()
|
||
return handler
|
||
}
|
||
|
||
_updateView() {
|
||
const dpi = this._dpi()
|
||
for (const page of this.activePages) {
|
||
this.pages[page.pageNumber].render(dpi, this.searchNeedle)
|
||
}
|
||
}
|
||
|
||
// TODO - remove?
|
||
_dpi() {
|
||
return ((this.zoomLevel * 96) / 100) | 0
|
||
}
|
||
|
||
goToPage(pageNumber) {
|
||
pageNumber = Math.max(0, Math.min(pageNumber, this.pages.length - 1))
|
||
this.pages[pageNumber].rootNode.scrollIntoView()
|
||
}
|
||
|
||
zoomIn() {
|
||
// TODO - instead find next larger zoom
|
||
let curr = zoomLevels.indexOf(this.zoomLevel)
|
||
let next = zoomLevels[curr + 1]
|
||
if (next)
|
||
this.setZoom(next)
|
||
}
|
||
|
||
zoomOut() {
|
||
let curr = zoomLevels.indexOf(this.zoomLevel)
|
||
let next = zoomLevels[curr - 1]
|
||
if (next)
|
||
this.setZoom(next)
|
||
}
|
||
|
||
setZoom(newZoom) {
|
||
if (this.zoomLevel === newZoom)
|
||
return
|
||
this.zoomLevel = newZoom
|
||
|
||
for (const page of this.pages) {
|
||
page.setZoom(newZoom)
|
||
}
|
||
this._updateView()
|
||
}
|
||
|
||
clearSearch() {
|
||
// TODO
|
||
}
|
||
|
||
setSearch(newNeedle) {
|
||
this.searchStatusDiv.textContent = ""
|
||
if (this.searchNeedle !== newNeedle) {
|
||
this.searchNeedle = newNeedle
|
||
this._updateView()
|
||
}
|
||
}
|
||
|
||
showSearchBox() {
|
||
// TODO - Fix what happens when you re-open search with existing text
|
||
this.searchDialogDiv.style.display = "block"
|
||
this.searchDivInput.focus()
|
||
this.searchDivInput.select()
|
||
this.setSearch(this.searchDivInput.value ?? "")
|
||
}
|
||
|
||
hideSearchBox() {
|
||
this.searchStatusDiv.textContent = ""
|
||
this.searchDialogDiv.style.display = "none"
|
||
this.cancelSearch()
|
||
this.setSearch("")
|
||
}
|
||
|
||
async runSearch(direction) {
|
||
let searchStatusDiv = this.searchStatusDiv
|
||
|
||
try {
|
||
let page = this.currentSearchPage + direction
|
||
while (page >= 1 && page < this.pageCount) {
|
||
// We run the check once per loop iteration,
|
||
// in case the search was cancel during the 'await' below.
|
||
if (this.searchNeedle === "") {
|
||
searchStatusDiv.textContent = ""
|
||
return
|
||
}
|
||
|
||
searchStatusDiv.textContent = `Searching page ${page}.`
|
||
|
||
await this.pages[page]._loadPageSearch(this._dpi(), this.searchNeedle)
|
||
const hits = this.pages[page].searchResultObject ?? []
|
||
if (hits.length > 0) {
|
||
this.pages[page].rootNode.scrollIntoView()
|
||
this.currentSearchPage = page
|
||
searchStatusDiv.textContent = `${hits.length} hits on page ${page}.`
|
||
return
|
||
}
|
||
|
||
page += direction
|
||
}
|
||
|
||
searchStatusDiv.textContent = "No more search hits."
|
||
} catch (error) {
|
||
console.error(`mupdf.runSearch: ${error.message}:\n${error.stack}`)
|
||
}
|
||
}
|
||
|
||
cancelSearch() {
|
||
// TODO
|
||
}
|
||
|
||
showOutline() {
|
||
this.gridSidebarDiv.style.display = "block"
|
||
this.gridMainDiv.classList.replace("sidebarHidden", "sidebarVisible")
|
||
}
|
||
|
||
hideOutline() {
|
||
this.gridSidebarDiv.style.display = "none"
|
||
this.gridMainDiv.classList.replace("sidebarVisible", "sidebarHidden")
|
||
}
|
||
|
||
toggleOutline() {
|
||
let node = this.gridSidebarDiv
|
||
if (node.style.display === "none" || node.style.display === "")
|
||
this.showOutline()
|
||
else
|
||
this.hideOutline()
|
||
}
|
||
|
||
_buildOutline(listNode, outline) {
|
||
for (let item of outline) {
|
||
let itemNode = document.createElement("li")
|
||
let aNode = document.createElement("a")
|
||
// TODO - document the "+ 1" better
|
||
aNode.href = `#page${item.page + 1}`
|
||
//aNode.href = `#page${item.page}`
|
||
//aNode.href = `#page${item.uri}`
|
||
//aNode.href = JSON.stringify(item)
|
||
//aNode.textContent = JSON.stringify(item)
|
||
aNode.textContent = item.title
|
||
itemNode.appendChild(aNode)
|
||
listNode.appendChild(itemNode)
|
||
if (item.down) {
|
||
itemNode = document.createElement("ul")
|
||
this._buildOutline(itemNode, item.down)
|
||
listNode.appendChild(itemNode)
|
||
}
|
||
}
|
||
}
|
||
|
||
clear() {
|
||
document.removeEventListener("scroll", this.scrollListener)
|
||
|
||
this.pagesDiv?.replaceChildren()
|
||
this.outlineNode?.replaceChildren()
|
||
this.searchDialogDiv?.replaceChildren()
|
||
|
||
for (let page of this.pages ?? []) {
|
||
page.clear()
|
||
}
|
||
this.pageObserver?.disconnect()
|
||
this.cancelSearch()
|
||
}
|
||
}
|
||
|
||
// TODO - Split into separate file
|
||
class MupdfDocumentViewer {
|
||
constructor(mupdfWorker) {
|
||
this.mupdfWorker = mupdfWorker
|
||
this.documentHandler = null
|
||
|
||
this.placeholderDiv = document.getElementById("placeholder")
|
||
this.viewerDivs = {
|
||
gridMenubarDiv: document.getElementById("grid-menubar"),
|
||
gridSidebarDiv: document.getElementById("grid-sidebar"),
|
||
gridMainDiv: document.getElementById("grid-main"),
|
||
pagesDiv: document.getElementById("pages"),
|
||
searchDialogDiv: document.getElementById("search-dialog"),
|
||
outlineNode: document.getElementById("outline"),
|
||
searchStatusDiv: document.getElementById("search-status"),
|
||
documentFontSize: document.getElementById("document-font-size"),
|
||
documentWidth: document.getElementById("document-width"),
|
||
documentHeight: document.getElementById("document-height"),
|
||
}
|
||
}
|
||
|
||
async openFile(file) {
|
||
|
||
try {
|
||
if (!(file instanceof File)) {
|
||
throw new Error(`Argument '${file}' is not a file`)
|
||
}
|
||
|
||
history.replaceState(null, null, window.location.pathname)
|
||
this.clear()
|
||
|
||
let loadingText = document.createElement("div")
|
||
loadingText.textContent = "Loading document..."
|
||
this.placeholderDiv.replaceChildren(loadingText)
|
||
|
||
await this.mupdfWorker.openDocumentFromBuffer(await file.arrayBuffer(), file.name)
|
||
await this._initDocument(file.name)
|
||
} catch (error) {
|
||
this.showDocumentError("openFile", error)
|
||
}
|
||
}
|
||
|
||
async openURL(url, progressive, prefetch) {
|
||
try {
|
||
this.clear()
|
||
|
||
let loadingText = document.createElement("div")
|
||
loadingText.textContent = "Loading document..."
|
||
this.placeholderDiv.replaceChildren(loadingText)
|
||
|
||
let headResponse = await fetch(url, { method: "HEAD" })
|
||
if (!headResponse.ok)
|
||
throw new Error("Could not fetch document.")
|
||
let acceptRanges = headResponse.headers.get("Accept-Ranges")
|
||
let contentLength = headResponse.headers.get("Content-Length")
|
||
let contentType = headResponse.headers.get("Content-Type")
|
||
// TODO - Log less stuff
|
||
console.log("HEAD", url)
|
||
console.log("Content-Length", contentLength)
|
||
console.log("Content-Type", contentType)
|
||
|
||
if (acceptRanges === "bytes" && progressive) {
|
||
console.log("USING HTTP RANGE REQUESTS")
|
||
await mupdfView.openDocumentFromUrl(url, contentLength, progressive, prefetch, contentType || url)
|
||
} else {
|
||
let bodyResponse = await fetch(url)
|
||
if (!bodyResponse.ok)
|
||
throw new Error("Could not fetch document.")
|
||
let buffer = await bodyResponse.arrayBuffer()
|
||
await mupdfView.openDocumentFromBuffer(buffer, contentType || url)
|
||
}
|
||
|
||
await this._initDocument(url)
|
||
} catch (error) {
|
||
this.showDocumentError("openURL", error)
|
||
}
|
||
}
|
||
|
||
openEmpty() {
|
||
this.clear()
|
||
this.placeholderDiv.replaceChildren()
|
||
|
||
// TODO - add "empty" placeholder
|
||
// add drag-and-drop support?
|
||
}
|
||
|
||
async _initDocument(docName) {
|
||
this.documentHandler = await MupdfDocumentHandler.createHandler(this.mupdfWorker, this.viewerDivs)
|
||
this.placeholderDiv.replaceChildren()
|
||
|
||
console.log("mupdf: Loaded", JSON.stringify(docName), "with", this.documentHandler.pageCount, "pages.")
|
||
|
||
// Change tab title
|
||
//document.title = this.documentHandler.title || docName
|
||
//document.getElementById("document-title").textContent = docName
|
||
}
|
||
|
||
showDocumentError(functionName, error) {
|
||
console.error(`mupdf.${functionName}: ${error.message}:\n${error.stack}`)
|
||
|
||
let errorDiv = document.createElement("div")
|
||
errorDiv.classList.add("error")
|
||
errorDiv.textContent = error.name + ": " + error.message
|
||
|
||
this.clear()
|
||
this.placeholderDiv.replaceChildren(errorDiv)
|
||
}
|
||
|
||
goToPage(pageNumber) {
|
||
this.documentHandler?.goToPage(pageNumber)
|
||
}
|
||
|
||
toggleFullscreen() {
|
||
if (!document.fullscreenElement) {
|
||
this.enterFullscreen()
|
||
} else {
|
||
this.exitFullscreen()
|
||
}
|
||
}
|
||
|
||
enterFullscreen() {
|
||
document.documentElement.requestFullscreen().catch((err) => {
|
||
console.error("Could not enter fullscreen mode:", err)
|
||
})
|
||
}
|
||
|
||
exitFullscreen() {
|
||
document.exitFullscreen()
|
||
}
|
||
|
||
zoomIn() {
|
||
this.documentHandler?.zoomIn()
|
||
}
|
||
|
||
zoomOut() {
|
||
this.documentHandler?.zoomOut()
|
||
}
|
||
|
||
setZoom(newZoom) {
|
||
this.documentHandler?.setZoom(newZoom)
|
||
}
|
||
|
||
clearSearch() {
|
||
this.documentHandler?.clearSearch()
|
||
}
|
||
|
||
setSearch(newNeedle) {
|
||
this.documentHandler?.setSearch(newNeedle)
|
||
}
|
||
|
||
showSearchBox() {
|
||
this.documentHandler?.showSearchBox()
|
||
}
|
||
|
||
hideSearchBox() {
|
||
this.documentHandler?.hideSearchBox()
|
||
}
|
||
|
||
runSearch(direction) {
|
||
this.documentHandler?.runSearch(direction)
|
||
}
|
||
|
||
cancelSearch() {
|
||
this.documentHandler?.cancelSearch()
|
||
}
|
||
|
||
showOutline() {
|
||
this.documentHandler?.showOutline()
|
||
}
|
||
|
||
hideOutline() {
|
||
this.documentHandler?.hideOutline()
|
||
}
|
||
|
||
toggleOutline() {
|
||
this.documentHandler?.toggleOutline()
|
||
}
|
||
|
||
clear() {
|
||
this.documentHandler?.clear()
|
||
// TODO
|
||
}
|
||
}
|