import { CSSResult, html, TemplateResult, LitElement, PropertyValueMap } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
// Type-only: erased at compile time, so these don't pull pdfjs-dist's runtime
// code into this module. The actual runtime values are loaded on demand by
// `loadPdfjsModules()` below — see the comment there for why.
import type {
PDFDocumentProxy,
PDFDocumentLoadingTask,
InvalidPDFException,
ResponseException,
} from 'pdfjs-dist';
import type {
EventBus,
PDFLinkService,
PDFViewer,
PDFFindController,
DownloadManager,
} from 'pdfjs-dist/web/pdf_viewer.mjs';
// Helpers
import { removeAccents } from '../helpers/remove-accents';
import { loadPdfjsLib, loadPdfjsViewer, PdfjsLib } from '../helpers/load-pdfjs';
// Components
import '../lit-pdf-toolbar/lit-pdf-toolbar';
import '../lit-pdf-pagination/lit-pdf-pagination';
import '../lit-pdf-thumbnails/lit-pdf-thumbnails';
import '../lit-pdf-error/lit-pdf-error';
import '../lit-pdf-search/lit-pdf-search';
// Services
import PdfPrintService from '../helpers/print';
import { IErrorInfo } from '../types/error';
import { getTranslations, Translations, TranslationsOverride } from '../i18n/i18n';
// @ts-ignore
import style from './lit-pdf-viewer.scss';
const DEFAULT_SCALE_DELTA = 1.1;
const MIN_SCALE = 0.25;
const MAX_SCALE = 10.0;
const MAX_IMAGE_SIZE = 1024 * 1024;
const CMAP_URL = 'pdfjs-dist/cmaps/';
const CMAP_PACKED = true;
const PAGINATION_HIDE_DELAY = 5000;
@customElement('lit-pdf-viewer')
export class LitPdfViewer extends LitElement {
/**
* Url of the PDF to download and display
*/
@property({ type: String }) public src: string;
/**
* If the print src is different from the displayed pdf document.
* Setting this property will download another document with the specified printSrc and print it
*/
@property({ type: String }) public printSrc: string;
@property({ type: String }) public token: string;
@property({ type: Array }) public searchQueries: string[] = [];
@property({ type: Boolean }) public entireWord = false;
@property({ type: String }) public scale = 'auto';
@property({ type: Number }) public scaleUpdateDelay = 300;
@property({ type: String }) public authorization: string;
@property({ type: Boolean, reflect: true }) public loaded: boolean;
/**
* Shows the floating find-in-page bar automatically when set (it also
* shows automatically whenever `searchQueries` is non-empty). It can also
* be opened at any time with the Ctrl+F / Cmd+F shortcut.
*/
@property({ type: Boolean, reflect: true }) public isSearchBarDisplayed = false;
/** Locale to translate the UI into (defaults to the browser's language). */
@property({ type: String }) public locale: string;
/** Overrides for individual translation strings, per namespace. */
@property({ type: Object }) public translations: TranslationsOverride = {};
@query('#viewerContainer') private _viewerContainer: HTMLDivElement;
@state() private _loadingPercent = 0;
@state() private _searchOpen = false;
@state() private _searchMatchCount = 0;
@state() private _searchCurrentMatch = 0;
@state() private _searchNotFound = false;
/** Prefills the search bar's input when it opens (e.g. from `searchQueries`). */
@state() private _initialSearchQuery = '';
private _searchQuery = '';
@state() private _errorMessage: string;
@state() private _errorMoreInfo: string;
@state() private _openErrorPanel: boolean;
@state() private _paginationVisible = false;
@state() private _sidebarOpen = false;
@state() private _isFullscreen = false;
@state() private _zoomPercent = 100;
@state() private _currentPage = 1;
private _searchQueriesNormalized: string[];
private _pageNumberEl: HTMLInputElement;
private _previousPageEl: HTMLButtonElement;
private _nextPageEl: HTMLButtonElement;
private _toolbarEl: HTMLElement;
private _paginationEl: HTMLElement;
private _paginationHideTimeout: ReturnType;
private _pdfLoadingTask: PDFDocumentLoadingTask;
private _pdfDocument: PDFDocumentProxy;
private _pdfViewer: PDFViewer;
private _pdfLinkService: PDFLinkService;
private _pdfFindController: PDFFindController;
private _downloadManager: DownloadManager;
private _eventBus: EventBus;
private _loadingTaskPromise: Promise;
private _pdfjsLib: PdfjsLib;
/** Resolves once pdfjs-dist's dynamically-imported modules are loaded and `_pdfViewer` etc. are constructed. */
private _viewerReadyPromise: Promise;
public static get styles(): CSSResult[] {
return [style];
}
public get pagesCount(): number {
return this._pdfDocument.numPages;
}
public get page(): number {
return this._pdfViewer.currentPageNumber;
}
private get _t(): Translations {
return getTranslations(this.locale, this.translations);
}
public set page(val) {
this._pdfViewer.currentPageNumber = val;
}
public connectedCallback(): void {
if (super.connectedCallback) {
super.connectedCallback();
}
// Set before the first render (rather than in `updated()`) so the bar is
// already open on the very first paint when `isSearchBarDisplayed` (or a
// non-empty `searchQueries`) is present in the initial markup.
this._searchOpen = this._shouldDisplaySearchBar();
this._searchQuery = this._initialSearchQuery = this._joinedSearchQueries();
window.addEventListener('resize', this._handleWindowResise);
window.addEventListener('keydown', this._handleGlobalKeydown);
this.addEventListener('fullscreenchange', this._handleFullscreenChange);
}
public disconnectedCallback(): void {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
window.removeEventListener('resize', this._handleWindowResise);
window.removeEventListener('keydown', this._handleGlobalKeydown);
this.removeEventListener('fullscreenchange', this._handleFullscreenChange);
this._viewerContainer?.removeEventListener('scroll', this._handleViewerScroll);
clearTimeout(this._paginationHideTimeout);
}
public render(): TemplateResult {
const t = this._t;
return html`
${t.viewer.loading.replace('{percent}', `${this._loadingPercent}`)}
this._toggleErrorPanel({ open: false })}>
`;
}
protected firstUpdated(): void {
this._viewerContainer.addEventListener('scroll', this._handleViewerScroll);
}
protected updated(_changedProperties: PropertyValueMap | Map): void {
if (_changedProperties.has('src')) {
this._loadingTaskPromise = this._open({
url: this.src,
});
}
if (_changedProperties.has('searchQueries') && this.searchQueries?.length) {
this._removeAccents();
this._searchWords();
this._searchQuery = this._initialSearchQuery = this._joinedSearchQueries();
}
if (_changedProperties.has('scale') && this.scale) {
// Only meaningful once a document has actually started loading (which
// is what initializes the viewer in the first place) — skip it
// otherwise instead of forcing pdfjs-dist's lazy chunk to load just
// because `scale` was set.
if (this._viewerReadyPromise) {
// Wait render before refresh pdfjs scale
setTimeout(async () => {
await this._viewerReadyPromise;
this._eventBus.dispatch('scalechanged', { value: this.scale });
}, this.scaleUpdateDelay);
}
}
if (_changedProperties.has('isSearchBarDisplayed') || _changedProperties.has('searchQueries')) {
this._searchOpen = this._shouldDisplaySearchBar();
}
}
private _shouldDisplaySearchBar(): boolean {
return this.isSearchBarDisplayed || this.searchQueries?.length > 0;
}
private _joinedSearchQueries(): string {
return this.searchQueries?.length ? this.searchQueries.join(', ') : '';
}
/**
* Opens PDF document specified by URL.
* @returns {Promise} - Returns the promise, which is resolved when document
* is opened.
*/
private async _open(params: { url: string }): Promise {
await this._ensureViewerReady();
if (this._pdfLoadingTask) {
// We need to destroy already opened document
return this.close().then(() => {
// ... and repeat the open() call.
return this._open(params);
});
}
const { url } = params;
this.loaded = false;
// Loading document.
this._pdfLoadingTask = this._pdfjsLib.getDocument({
url,
maxImageSize: MAX_IMAGE_SIZE,
cMapUrl: CMAP_URL,
cMapPacked: CMAP_PACKED,
});
this._pdfLoadingTask.onProgress = (progressData: { loaded: number; total: number }): void => {
this.progress(progressData.loaded / progressData.total);
};
// Document loaded, specifying document for the viewer.
try {
this._pdfDocument = await this._pdfLoadingTask.promise;
this._pdfViewer.setDocument(this._pdfDocument);
this._pdfLinkService.setDocument(this._pdfDocument);
this._pdfFindController.setDocument(this._pdfDocument);
this._paginationEl.setAttribute('pageCount', `${this.pagesCount}`);
this._toolbarEl.toggleAttribute('isDownloadDisabled', false);
this._toolbarEl.toggleAttribute('isPrintDisabled', false);
this.loaded = true;
} catch (exception) {
this._handleError(exception);
this.loaded = true;
}
return this._pdfDocument;
}
private _handleError(exception: InvalidPDFException | ResponseException): void {
const message = exception?.message;
const errorTranslations = this._t.error;
let loadingErrorMessage: string;
if (exception instanceof this._pdfjsLib.InvalidPDFException) {
loadingErrorMessage = errorTranslations.invalidPdf;
} else if (exception instanceof this._pdfjsLib.ResponseException && exception.missing) {
// special message for missing PDFs (e.g. HTTP 404)
loadingErrorMessage = errorTranslations.missingPdf;
} else if (exception instanceof this._pdfjsLib.ResponseException) {
loadingErrorMessage = errorTranslations.unexpectedResponse;
} else {
loadingErrorMessage = errorTranslations.genericError;
}
this.error(loadingErrorMessage, { message });
}
private error(message: string, moreInfo: IErrorInfo): void {
const errorTranslations = this._t.error;
const moreInfoText = [
`PDF.js v${this._pdfjsLib.version || '?'} (build: ${this._pdfjsLib.build || '?'})`,
];
this._errorMessage = message;
this._toggleErrorPanel({ open: true });
if (moreInfo) {
moreInfoText.push(`${errorTranslations.messageLabel}: ${moreInfo.message}`);
if (moreInfo.stack) {
moreInfoText.push(`${errorTranslations.stackLabel}: ${moreInfo.stack}`);
} else {
if (moreInfo.filename) {
moreInfoText.push(`${errorTranslations.fileLabel}: ${moreInfo.filename}`);
}
if (moreInfo.lineNumber) {
moreInfoText.push(`${errorTranslations.lineLabel}: ${moreInfo.lineNumber}`);
}
}
}
this._errorMoreInfo = moreInfoText.join('\n');
}
private _toggleErrorPanel({ open }: { open: boolean }): void {
this._openErrorPanel = open;
}
/**
* Closes opened PDF document.
* @returns {Promise} - Returns the promise, which is resolved when all
* destruction is completed.
*/
private close(): Promise {
this._toggleErrorPanel({ open: false });
if (!this._pdfLoadingTask) {
return Promise.resolve();
}
const promise = this._pdfLoadingTask.destroy();
this._pdfLoadingTask = null;
if (this._pdfDocument) {
this._pdfDocument = null;
this._pdfViewer.setDocument(null);
this._pdfLinkService.setDocument(null, null);
}
return promise;
}
private progress(level: number): void {
const percent = Math.round(level * 100);
// Updating the bar if value increases.
if (percent > this._loadingPercent || Number.isNaN(percent)) {
this._loadingPercent = percent;
}
}
// Lazily kicks off `initViewer()` (and with it, pdfjs-dist's dynamically
// imported chunk) on first actual need — i.e. once a document is being
// opened — rather than unconditionally on connect, so mounting
// `` without a `src` yet never pays for pdfjs-dist.
private _ensureViewerReady(): Promise {
if (!this._viewerReadyPromise) {
this._viewerReadyPromise = this.initViewer();
}
return this._viewerReadyPromise;
}
private async initViewer(): Promise {
const [pdfjsLib, pdfjsViewerModule] = await Promise.all([loadPdfjsLib(), loadPdfjsViewer()]);
const { EventBus, PDFLinkService, PDFViewer, PDFFindController, DownloadManager, FindState } =
pdfjsViewerModule;
this._pdfjsLib = pdfjsLib;
this._eventBus = new EventBus();
this._pdfLinkService = new PDFLinkService({
eventBus: this._eventBus,
});
this._pdfFindController = new PDFFindController({
linkService: this._pdfLinkService,
eventBus: this._eventBus,
});
this._downloadManager = new DownloadManager();
this._pdfViewer = new PDFViewer({
container: this._viewerContainer,
removePageBorders: true,
eventBus: this._eventBus,
linkService: this._pdfLinkService,
findController: this._pdfFindController,
downloadManager: this._downloadManager,
});
this._pdfLinkService.setViewer(this._pdfViewer);
this._eventBus.on(
'pagechanging',
evt => {
const page = evt.pageNumber;
this._pageNumberEl.value = page;
this._previousPageEl.disabled = page <= 1;
this._nextPageEl.disabled = page >= this.pagesCount;
this._currentPage = page;
},
true,
);
this._eventBus.on('pagesinit', () => {
// We can use _pdfViewer now, e.g. let's change default scale.
this._pdfViewer.currentScaleValue = this.scale;
});
this._eventBus.on('scalechanging', ({ scale }) => {
this._zoomPercent = Math.round(scale * 100);
});
this._eventBus.on('updatefindmatchescount', ({ matchesCount }) => {
this._searchMatchCount = matchesCount.total;
this._searchCurrentMatch = matchesCount.current;
});
this._eventBus.on('updatefindcontrolstate', ({ state, matchesCount }) => {
this._searchMatchCount = matchesCount.total;
this._searchCurrentMatch = matchesCount.current;
this._searchNotFound = state === FindState.NOT_FOUND;
});
}
private _handleZoomIn(ticks: number): void {
let newScale = this._pdfViewer.currentScale;
do {
newScale = Number((newScale * DEFAULT_SCALE_DELTA).toFixed(2));
newScale = Math.ceil(newScale * 10) / 10;
newScale = Math.min(MAX_SCALE, newScale);
} while (--ticks && newScale < MAX_SCALE);
this._pdfViewer.currentScaleValue = newScale.toString();
}
private _handleZoomOut(ticks: number): void {
let newScale = this._pdfViewer.currentScale;
do {
newScale = Number((newScale / DEFAULT_SCALE_DELTA).toFixed(2));
newScale = Math.floor(newScale * 10) / 10;
newScale = Math.max(MIN_SCALE, newScale);
} while (--ticks && newScale > MIN_SCALE);
this._pdfViewer.currentScaleValue = newScale.toString();
}
private _handleZoomChange(e: CustomEvent<{ value: number }>): void {
const clampedPercent = Math.min(MAX_SCALE * 100, Math.max(MIN_SCALE * 100, e.detail.value));
this._pdfViewer.currentScaleValue = (clampedPercent / 100).toString();
}
private _handleRotateCw(): void {
this._pdfViewer.pagesRotation += 90;
}
private _handleRotateCcw(): void {
this._pdfViewer.pagesRotation -= 90;
}
private _handleToggleSidebar(): void {
this._sidebarOpen = !this._sidebarOpen;
}
private _handleThumbnailSelect(e: CustomEvent<{ pageNumber: number }>): void {
this.page = e.detail.pageNumber;
// On narrow screens the sidebar overlays the document instead of sharing
// the row with it, so close it once a page has been picked to reveal
// what was just navigated to.
if (window.matchMedia('(max-width: 600px)').matches) {
this._sidebarOpen = false;
}
}
private _handlePrevious(): void {
this.page -= 1;
}
private _handleNext(): void {
this.page += 1;
}
private _handlePageChange(): void {
const input = this._pageNumberEl;
this.page = input.value !== undefined ? Number(input.value) : 0;
// Ensure that the page number input displays the correct value,
// even if the value entered by the user was invalid
// (e.g. a floating point number).
if (input.value !== this.page.toString()) {
input.value = this.page.toString();
}
}
private async _handlePrint(e: CustomEvent): Promise {
const toolbar = e.target;
toolbar.toggleAttribute('isPrintDisabled', true);
const printService = PdfPrintService.getInstance();
await printService.print({
printSrc: this.printSrc,
token: this.token,
pdfDocument: this._pdfDocument,
});
printService.onProgress = (progressData: { loaded: number; total: number }): void => {
const progress = progressData.loaded / progressData.total;
this.progress(progress);
this.loaded = progress === 1;
};
toolbar.toggleAttribute('isPrintDisabled', false);
}
private async _handleDownload(e: CustomEvent): Promise {
const toolbar = e.target;
toolbar.toggleAttribute('isDownloadDisabled', true);
const data = await this._pdfDocument.getData();
this._downloadManager.download(data, this.src, this._pdfjsLib.getFilenameFromUrl(this.src));
toolbar.toggleAttribute('isDownloadDisabled', false);
}
private _handleToolbarConnected(e: CustomEvent): void {
this._toolbarEl = e.target;
}
private _handlePaginationConnected(e: CustomEvent): void {
this._paginationEl = e.target;
this._pageNumberEl = e.detail.pageNumberEl;
this._previousPageEl = e.detail.previousPageEl;
this._nextPageEl = e.detail.nextPageEl;
}
private _handleViewerScroll = (): void => {
this._paginationVisible = true;
clearTimeout(this._paginationHideTimeout);
this._paginationHideTimeout = setTimeout(() => {
this._paginationVisible = false;
}, PAGINATION_HIDE_DELAY);
};
private _handleGlobalKeydown = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f') {
e.preventDefault();
this._searchOpen = true;
}
};
private _handleToggleSearch(): void {
if (this._searchOpen) {
this._handleSearchClose();
} else {
this._searchOpen = true;
}
}
private async _handleToggleFullscreen(): Promise {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await this.requestFullscreen();
}
}
private _handleFullscreenChange = (): void => {
this._isFullscreen = document.fullscreenElement === this;
};
private _handleSearchQuery(e: CustomEvent<{ query: string }>): void {
this._searchQuery = e.detail.query;
// An emptied input is the only thing that should drop the highlighting;
// closing the bar must leave it intact (see `_handleSearchClose`).
if (!this._searchQuery.trim()) {
this._clearHighlights();
return;
}
this._dispatchFind({ type: '', findPrevious: false });
}
private _handleSearchNext(): void {
this._dispatchFind({ type: 'again', findPrevious: false });
}
private _handleSearchPrevious(): void {
this._dispatchFind({ type: 'again', findPrevious: true });
}
private _handleSearchClose(): void {
// Only hides the bar. Highlights (and their match count/current index)
// are left untouched so they're still there if the bar is reopened —
// matching find-in-page behavior in browsers.
this._searchOpen = false;
}
private _clearHighlights(): void {
this._searchMatchCount = 0;
this._searchCurrentMatch = 0;
this._searchNotFound = false;
this._eventBus.dispatch('findbarclose', { source: this });
}
private _dispatchFind({ type, findPrevious }: { type: string; findPrevious: boolean }): void {
const query = this._searchQuery
.split(',')
.map(term => removeAccents(term.trim()))
.filter(Boolean);
if (!query.length) {
return;
}
this._eventBus.dispatch('find', {
source: this,
type,
query,
caseSensitive: false,
entireWord: false,
findPrevious,
highlightAll: true,
matchDiacritics: false,
});
}
private _handleWindowResise(): void {
const { currentScaleValue } = this._pdfViewer;
if (
currentScaleValue === 'auto' ||
currentScaleValue === 'page-fit' ||
currentScaleValue === 'page-width'
) {
// Note: the scale is constant for 'page-actual'.
this._pdfViewer.currentScaleValue = currentScaleValue;
}
this._pdfViewer.update();
}
private async _searchWords(): Promise {
await this._loadingTaskPromise;
await this.updateComplete;
setTimeout(() => {
this._eventBus.dispatch('find', {
source: this,
type: '',
// Since v6 the find controller natively supports an array of queries
// for multi-phrase search, replacing the previous internal hack.
query: this._searchQueriesNormalized,
caseSensitive: false,
entireWord: this.entireWord,
findPrevious: false,
highlightAll: true,
matchDiacritics: false,
});
}, 500);
}
private _removeAccents(): void {
this._searchQueriesNormalized = this.searchQueries.map(query => removeAccents(query));
}
}