// 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 // // Alternative licensing terms are available from the licensor. // For commercial licensing, see 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 } }