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
|
|||
|
|
}
|
|||
|
|
}
|