/* Copyright 2012 Mozilla Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** @typedef {import("../src/display/api").PDFDocumentProxy} PDFDocumentProxy */ /** @typedef {import("../src/display/api").PDFPageProxy} PDFPageProxy */ /** @typedef {import("./event_utils").EventBus} EventBus */ /** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */ // eslint-disable-next-line max-len /** @typedef {import("./pdf_rendering_queue").PDFRenderingQueue} PDFRenderingQueue */ import { getVisibleElements, isValidRotation, RenderingStates, watchScroll, } from "./ui_utils.js"; import { MathClamp, stopEvent } from "pdfjs-lib"; import { PDFThumbnailView } from "./pdf_thumbnail_view.js"; const SCROLL_OPTIONS = { behavior: "instant", block: "nearest", inline: "nearest", container: "nearest", }; /** * @typedef {Object} PDFThumbnailViewerOptions * @property {HTMLDivElement} container - The container for the thumbnail * elements. * @property {EventBus} eventBus - The application event bus. * @property {IPDFLinkService} linkService - The navigation/linking service. * @property {PDFRenderingQueue} renderingQueue - The rendering queue object. * @property {number} [maxCanvasPixels] - The maximum supported canvas size in * total pixels, i.e. width * height. Use `-1` for no limit, or `0` for * CSS-only zooming. The default value is 4096 * 8192 (32 mega-pixels). * @property {number} [maxCanvasDim] - The maximum supported canvas dimension, * in either width or height. Use `-1` for no limit. * The default value is 32767. * @property {Object} [pageColors] - Overwrites background and foreground colors * with user defined ones in order to improve readability in high contrast * mode. * @property {AbortSignal} [abortSignal] - The AbortSignal for the window * events. * @property {boolean} [enableHWA] - Enables hardware acceleration for * rendering. The default value is `false`. */ /** * Viewer control to display thumbnails for pages in a PDF document. */ class PDFThumbnailViewer { /** * @param {PDFThumbnailViewerOptions} options */ constructor({ container, eventBus, linkService, renderingQueue, maxCanvasPixels, maxCanvasDim, pageColors, abortSignal, enableHWA, }) { this.scrollableContainer = container.parentElement; this.container = container; this.eventBus = eventBus; this.linkService = linkService; this.renderingQueue = renderingQueue; this.maxCanvasPixels = maxCanvasPixels; this.maxCanvasDim = maxCanvasDim; this.pageColors = pageColors || null; this.enableHWA = enableHWA || false; this.scroll = watchScroll( this.scrollableContainer, this.#scrollUpdated.bind(this), abortSignal ); this.#resetView(); this.#addEventListeners(); } #scrollUpdated() { this.renderingQueue.renderHighestPriority(); } getThumbnail(index) { return this._thumbnails[index]; } #getVisibleThumbs() { return getVisibleElements({ scrollEl: this.scrollableContainer, views: this._thumbnails, }); } scrollThumbnailIntoView(pageNumber) { if (!this.pdfDocument) { return; } const thumbnailView = this._thumbnails[pageNumber - 1]; if (!thumbnailView) { console.error('scrollThumbnailIntoView: Invalid "pageNumber" parameter.'); return; } if (pageNumber !== this._currentPageNumber) { const prevThumbnailView = this._thumbnails[this._currentPageNumber - 1]; prevThumbnailView.toggleCurrent(/* isCurrent = */ false); thumbnailView.toggleCurrent(/* isCurrent = */ true); this._currentPageNumber = pageNumber; } const { first, last, views } = this.#getVisibleThumbs(); // If the thumbnail isn't currently visible, scroll it into view. if (views.length > 0) { let shouldScroll = false; if (pageNumber <= first.id || pageNumber >= last.id) { shouldScroll = true; } else { for (const { id, percent } of views) { if (id !== pageNumber) { continue; } shouldScroll = percent < 100; break; } } if (shouldScroll) { thumbnailView.div.scrollIntoView(SCROLL_OPTIONS); } } this._currentPageNumber = pageNumber; } get pagesRotation() { return this._pagesRotation; } set pagesRotation(rotation) { if (!isValidRotation(rotation)) { throw new Error("Invalid thumbnails rotation angle."); } if (!this.pdfDocument) { return; } if (this._pagesRotation === rotation) { return; // The rotation didn't change. } this._pagesRotation = rotation; const updateArgs = { rotation }; for (const thumbnail of this._thumbnails) { thumbnail.update(updateArgs); } } cleanup() { for (const thumbnail of this._thumbnails) { if (thumbnail.renderingState !== RenderingStates.FINISHED) { thumbnail.reset(); } } } #resetView() { this._thumbnails = []; this._currentPageNumber = 1; this._pageLabels = null; this._pagesRotation = 0; // Remove the thumbnails from the DOM. this.container.textContent = ""; } /** * @param {PDFDocumentProxy} pdfDocument */ setDocument(pdfDocument) { if (this.pdfDocument) { this.#cancelRendering(); this.#resetView(); } this.pdfDocument = pdfDocument; if (!pdfDocument) { return; } const firstPagePromise = pdfDocument.getPage(1); const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig({ intent: "display", }); firstPagePromise .then(firstPdfPage => { const pagesCount = pdfDocument.numPages; const viewport = firstPdfPage.getViewport({ scale: 1 }); const fragment = document.createDocumentFragment(); for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) { const thumbnail = new PDFThumbnailView({ container: fragment, eventBus: this.eventBus, id: pageNum, defaultViewport: viewport.clone(), optionalContentConfigPromise, linkService: this.linkService, renderingQueue: this.renderingQueue, maxCanvasPixels: this.maxCanvasPixels, maxCanvasDim: this.maxCanvasDim, pageColors: this.pageColors, enableHWA: this.enableHWA, }); this._thumbnails.push(thumbnail); } // Set the first `pdfPage` immediately, since it's already loaded, // rather than having to repeat the `PDFDocumentProxy.getPage` call in // the `this.#ensurePdfPageLoaded` method before rendering can start. this._thumbnails[0]?.setPdfPage(firstPdfPage); // Ensure that the current thumbnail is always highlighted on load. const thumbnailView = this._thumbnails[this._currentPageNumber - 1]; thumbnailView.toggleCurrent(/* isCurrent = */ true); this.container.append(fragment); }) .catch(reason => { console.error("Unable to initialize thumbnail viewer", reason); }); } #cancelRendering() { for (const thumbnail of this._thumbnails) { thumbnail.cancelRendering(); } } /** * @param {Array|null} labels */ setPageLabels(labels) { if (!this.pdfDocument) { return; } if (!labels) { this._pageLabels = null; } else if ( !(Array.isArray(labels) && this.pdfDocument.numPages === labels.length) ) { this._pageLabels = null; console.error("PDFThumbnailViewer_setPageLabels: Invalid page labels."); } else { this._pageLabels = labels; } // Update all the `PDFThumbnailView` instances. for (let i = 0, ii = this._thumbnails.length; i < ii; i++) { this._thumbnails[i].setPageLabel(this._pageLabels?.[i] ?? null); } } /** * @param {PDFThumbnailView} thumbView * @returns {Promise} */ async #ensurePdfPageLoaded(thumbView) { if (thumbView.pdfPage) { return thumbView.pdfPage; } try { const pdfPage = await this.pdfDocument.getPage(thumbView.id); if (!thumbView.pdfPage) { thumbView.setPdfPage(pdfPage); } return pdfPage; } catch (reason) { console.error("Unable to get page for thumb view", reason); return null; // Page error -- there is nothing that can be done. } } #getScrollAhead(visible) { if (visible.first?.id === 1) { return true; } else if (visible.last?.id === this._thumbnails.length) { return false; } return this.scroll.down; } forceRendering() { const visibleThumbs = this.#getVisibleThumbs(); const scrollAhead = this.#getScrollAhead(visibleThumbs); const thumbView = this.renderingQueue.getHighestPriority( visibleThumbs, this._thumbnails, scrollAhead, /* preRenderExtra */ false, /* ignoreDetailViews */ true ); if (thumbView) { this.#ensurePdfPageLoaded(thumbView).then(() => { this.renderingQueue.renderView(thumbView); }); return true; } return false; } #addEventListeners() { this.container.addEventListener("keydown", e => { switch (e.key) { case "ArrowLeft": this.#goToNextItem(e.target, false, true); stopEvent(e); break; case "ArrowRight": this.#goToNextItem(e.target, true, true); stopEvent(e); break; case "ArrowDown": this.#goToNextItem(e.target, true, false); stopEvent(e); break; case "ArrowUp": this.#goToNextItem(e.target, false, false); stopEvent(e); break; case "Home": this._thumbnails[0].image.focus(); stopEvent(e); break; case "End": this._thumbnails.at(-1).image.focus(); stopEvent(e); break; case "Enter": case " ": this.#goToPage(e); break; } }); this.container.addEventListener("click", this.#goToPage.bind(this)); } #goToPage(e) { const { target } = e; if (target.classList.contains("thumbnailImage")) { const pageNumber = parseInt( target.parentElement.getAttribute("page-number"), 10 ); this.linkService.goToPage(pageNumber); stopEvent(e); } } /** * Go to the next/previous menu item. * @param {HTMLElement} element * @param {boolean} forward * @param {boolean} horizontal */ #goToNextItem(element, forward, horizontal) { let currentPageNumber = parseInt( element.parentElement.getAttribute("page-number"), 10 ); if (isNaN(currentPageNumber)) { currentPageNumber = this._currentPageNumber; } const increment = forward ? 1 : -1; let nextThumbnail; if (horizontal) { const nextPageNumber = MathClamp( currentPageNumber + increment, 1, this._thumbnails.length + 1 ); nextThumbnail = this._thumbnails[nextPageNumber - 1]; } else { const currentThumbnail = this._thumbnails[currentPageNumber - 1]; const { x: currentX, y: currentY } = currentThumbnail.div.getBoundingClientRect(); let firstWithDifferentY; for ( let i = currentPageNumber - 1 + increment; i >= 0 && i < this._thumbnails.length; i += increment ) { const thumbnail = this._thumbnails[i]; const { x, y } = thumbnail.div.getBoundingClientRect(); if (!firstWithDifferentY && y !== currentY) { firstWithDifferentY = thumbnail; } if (x === currentX) { nextThumbnail = thumbnail; break; } } if (!nextThumbnail) { nextThumbnail = firstWithDifferentY; } } if (nextThumbnail) { nextThumbnail.image.focus(); } } } export { PDFThumbnailViewer };