import { DeesElement, property, html, customElement, domtools, type TemplateResult, type CSSResult, css, cssManager } from '@design.estate/dees-element'; import { keyed } from 'lit/directives/keyed.js'; import { repeat } from 'lit/directives/repeat.js'; import { DeesInputBase } from '../dees-input-base.js'; import { PdfManager } from '../dees-pdf-shared/PdfManager.js'; import { viewerStyles } from './styles.js'; import { demo as demoFunc } from './demo.js'; import { DeesContextmenu } from '../dees-contextmenu.js'; import '../dees-icon.js'; declare global { interface HTMLElementTagNameMap { 'dees-pdf-viewer': DeesPdfViewer; } } type RenderState = 'idle' | 'loading' | 'rendering-main' | 'rendering-thumbs' | 'rendered' | 'error' | 'disposed'; @customElement('dees-pdf-viewer') export class DeesPdfViewer extends DeesElement { public static demo = demoFunc; public static styles = viewerStyles; @property({ type: String }) public pdfUrl: string = ''; @property({ type: Number }) public initialPage: number = 1; @property({ type: String }) public initialZoom: 'auto' | 'page-fit' | 'page-width' | number = 'auto'; @property({ type: Boolean }) public showToolbar: boolean = true; @property({ type: Boolean }) public showSidebar: boolean = false; @property({ type: Number }) private currentPage: number = 1; @property({ type: Number }) private totalPages: number = 1; @property({ type: Number }) private currentZoom: number = 1; @property({ type: Boolean }) private loading: boolean = false; @property({ type: String }) private documentId: string = ''; @property({ type: Array }) private thumbnailData: Array<{page: number, rendered: boolean}> = []; private pdfDocument: any; private renderState: RenderState = 'idle'; private renderAbortController: AbortController | null = null; private pageRendering: boolean = false; private pageNumPending: number | null = null; private currentRenderTask: any = null; private currentRenderPromise: Promise | null = null; private thumbnailRenderTasks: any[] = []; private canvas: HTMLCanvasElement | undefined; private ctx: CanvasRenderingContext2D | undefined; private viewerMain: HTMLElement | null = null; private resizeObserver?: ResizeObserver; private viewportDimensions = { width: 0, height: 0 }; private viewportMode: 'auto' | 'page-fit' | 'page-width' | 'custom' = 'auto'; private loadedPdfUrl: string | null = null; private readonly MANUAL_MIN_ZOOM = 0.5; private readonly MANUAL_MAX_ZOOM = 3; private readonly ABSOLUTE_MIN_ZOOM = 0.1; private readonly ABSOLUTE_MAX_ZOOM = 4; constructor() { super(); } public render(): TemplateResult { return html`
${this.showToolbar ? html`
/ ${this.totalPages}
` : ''}
${this.showSidebar ? html` ` : ''}
${this.loading ? html`
Loading PDF...
` : html`
`}
`; } public async connectedCallback() { await super.connectedCallback(); await this.updateComplete; this.ensureViewerRefs(); // Generate a unique document ID for this connection if (this.pdfUrl) { this.documentId = `${this.pdfUrl}-${Date.now()}-${Math.random()}`; await this.loadPdf(); } } public async disconnectedCallback() { await super.disconnectedCallback(); this.resizeObserver?.disconnect(); this.resizeObserver = undefined; // Mark as disposed and clean up this.renderState = 'disposed'; await this.cleanupDocument(); // Clear all references this.canvas = undefined; this.ctx = undefined; } public async updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('pdfUrl') && this.pdfUrl) { const previousUrl = changedProperties.get('pdfUrl') as string | undefined; if (previousUrl) { PdfManager.releaseDocument(previousUrl); } // Generate new document ID for new URL this.documentId = `${this.pdfUrl}-${Date.now()}-${Math.random()}`; await this.loadPdf(); } // Only re-render thumbnails when sidebar becomes visible and document is loaded if (changedProperties.has('showSidebar') && this.showSidebar && this.pdfDocument && this.renderState === 'rendered') { // Use requestAnimationFrame to ensure DOM is ready await new Promise(resolve => requestAnimationFrame(resolve)); await this.renderThumbnails(); } } private async loadPdf() { this.loading = true; this.renderState = 'loading'; try { await this.cleanupDocument(); // Create new abort controller for this load operation this.renderAbortController = new AbortController(); const signal = this.renderAbortController.signal; this.pdfDocument = await PdfManager.loadDocument(this.pdfUrl); if (signal.aborted) return; this.totalPages = this.pdfDocument.numPages; this.currentPage = this.initialPage; this.resolveInitialViewportMode(); // Initialize thumbnail data array this.thumbnailData = Array.from({length: this.totalPages}, (_, i) => ({ page: i + 1, rendered: false })); // Set loading to false to render the canvas this.loading = false; await this.updateComplete; this.ensureViewerRefs(); // Wait for next frame to ensure DOM is ready await new Promise(resolve => requestAnimationFrame(resolve)); if (signal.aborted) return; // Always re-acquire canvas references this.canvas = this.shadowRoot?.querySelector('#pdf-canvas') as HTMLCanvasElement; if (!this.canvas) { console.error('Canvas element not found in DOM'); this.renderState = 'error'; return; } this.ctx = this.canvas.getContext('2d'); if (!this.ctx) { console.error('Failed to acquire 2D rendering context'); this.renderState = 'error'; return; } this.renderState = 'rendering-main'; await this.renderPage(this.currentPage); if (signal.aborted) return; if (this.showSidebar) { // Ensure sidebar is in DOM after loading = false await this.updateComplete; // Wait for next frame to ensure DOM is fully ready await new Promise(resolve => requestAnimationFrame(resolve)); if (signal.aborted) return; await this.renderThumbnails(); if (signal.aborted) return; } this.renderState = 'rendered'; this.loadedPdfUrl = this.pdfUrl; } catch (error) { console.error('Error loading PDF:', error); this.loading = false; this.renderState = 'error'; } } private async renderPage(pageNum: number) { if (!this.pdfDocument || !this.canvas || !this.ctx) return; // Wait for any existing render to complete if (this.currentRenderPromise) { try { await this.currentRenderPromise; } catch (error) { // Ignore errors from previous renders } } // Create a new promise for this render this.currentRenderPromise = this._doRenderPage(pageNum); try { await this.currentRenderPromise; } finally { this.currentRenderPromise = null; } } private async _doRenderPage(pageNum: number) { if (!this.pdfDocument || !this.canvas || !this.ctx) return; this.pageRendering = true; try { const page = await this.pdfDocument.getPage(pageNum); if (!this.ctx) { console.error('Unable to acquire canvas rendering context'); this.pageRendering = false; return; } const viewport = this.computeViewport(page); this.canvas.height = viewport.height; this.canvas.width = viewport.width; this.canvas.style.width = `${viewport.width}px`; this.canvas.style.height = `${viewport.height}px`; const renderContext = { canvasContext: this.ctx, viewport: viewport, }; // Store the render task this.currentRenderTask = page.render(renderContext); await this.currentRenderTask.promise; this.currentRenderTask = null; this.pageRendering = false; // Clean up the page object page.cleanup?.(); if (this.pageNumPending !== null) { const nextPage = this.pageNumPending; this.pageNumPending = null; await this.renderPage(nextPage); } } catch (error) { // Ignore cancellation errors if (error?.name !== 'RenderingCancelledException') { console.error('Error rendering page:', error); } this.currentRenderTask = null; this.pageRendering = false; } } private queueRenderPage(pageNum: number) { if (this.pageRendering) { this.pageNumPending = pageNum; } else { this.renderPage(pageNum); } } private async renderThumbnails() { // Check if document is loaded if (!this.pdfDocument) { return; } // Check if already rendered if (this.thumbnailData.length > 0 && this.thumbnailData.every(t => t.rendered)) { return; } // Check abort signal if (this.renderAbortController?.signal.aborted) { return; } const signal = this.renderAbortController?.signal; this.renderState = 'rendering-thumbs'; // Cancel any existing thumbnail render tasks for (const task of this.thumbnailRenderTasks) { try { task.cancel(); } catch (error) { // Ignore cancellation errors } } this.thumbnailRenderTasks = []; try { await this.updateComplete; const thumbnails = this.shadowRoot?.querySelectorAll('.thumbnail-canvas') as NodeListOf; const thumbnailWidth = 176; // Fixed width for thumbnails (200px container - 24px padding) // Clear all canvases first to prevent conflicts for (const canvas of Array.from(thumbnails)) { const context = canvas.getContext('2d'); if (context) { context.clearRect(0, 0, canvas.width, canvas.height); } } for (const canvas of Array.from(thumbnails)) { if (signal?.aborted) return; const pageNum = parseInt(canvas.dataset.page || '1'); const page = await this.pdfDocument.getPage(pageNum); // Calculate scale to fit thumbnail width while maintaining aspect ratio const initialViewport = page.getViewport({ scale: 1 }); const scale = thumbnailWidth / initialViewport.width; const viewport = page.getViewport({ scale }); // Set canvas dimensions to actual render size canvas.width = viewport.width; canvas.height = viewport.height; // Also set the display size via style to ensure proper display canvas.style.width = `${viewport.width}px`; canvas.style.height = `${viewport.height}px`; const context = canvas.getContext('2d'); if (!context) { page.cleanup?.(); continue; } const renderContext = { canvasContext: context, viewport: viewport, }; const renderTask = page.render(renderContext); this.thumbnailRenderTasks.push(renderTask); await renderTask.promise; page.cleanup?.(); // Mark this thumbnail as rendered const thumbData = this.thumbnailData.find(t => t.page === pageNum); if (thumbData) { thumbData.rendered = true; } } // Trigger update to reflect rendered state this.requestUpdate('thumbnailData'); } catch (error: any) { // Only log non-cancellation errors if (error?.name !== 'RenderingCancelledException') { console.error('Error rendering thumbnails:', error); } } finally { this.thumbnailRenderTasks = []; } } private previousPage() { if (this.currentPage > 1) { this.currentPage--; this.queueRenderPage(this.currentPage); } } private nextPage() { if (this.currentPage < this.totalPages) { this.currentPage++; this.queueRenderPage(this.currentPage); } } private async goToPage(pageNum: number) { if (pageNum >= 1 && pageNum <= this.totalPages) { this.currentPage = pageNum; // Ensure canvas references are available if (!this.canvas || !this.ctx) { await this.updateComplete; this.canvas = this.shadowRoot?.querySelector('#pdf-canvas') as HTMLCanvasElement; this.ctx = this.canvas?.getContext('2d') || null; } if (this.canvas && this.ctx) { this.queueRenderPage(this.currentPage); } } } private handleThumbnailClick(e: Event) { const target = e.currentTarget as HTMLElement; const pageNum = parseInt(target.dataset.page || '1'); this.goToPage(pageNum); } private handlePageInput(e: Event) { const input = e.target as HTMLInputElement; const pageNum = parseInt(input.value); this.goToPage(pageNum); } private zoomIn() { const nextZoom = Math.min(this.MANUAL_MAX_ZOOM, this.currentZoom * 1.2); this.viewportMode = 'custom'; if (nextZoom !== this.currentZoom) { this.currentZoom = nextZoom; this.queueRenderPage(this.currentPage); } } private zoomOut() { const nextZoom = Math.max(this.MANUAL_MIN_ZOOM, this.currentZoom / 1.2); this.viewportMode = 'custom'; if (nextZoom !== this.currentZoom) { this.currentZoom = nextZoom; this.queueRenderPage(this.currentPage); } } private resetZoom() { this.viewportMode = 'custom'; this.currentZoom = 1; this.queueRenderPage(this.currentPage); } private fitToPage() { this.viewportMode = 'page-fit'; this.queueRenderPage(this.currentPage); } private fitToWidth() { this.viewportMode = 'page-width'; this.queueRenderPage(this.currentPage); } private downloadPdf() { const link = document.createElement('a'); link.href = this.pdfUrl; link.download = this.pdfUrl.split('/').pop() || 'document.pdf'; link.click(); } private printPdf() { window.open(this.pdfUrl, '_blank')?.print(); } /** * Provide context menu items for right-click functionality */ public getContextMenuItems() { return [ { name: 'Open PDF in New Tab', iconName: 'lucide:ExternalLink', action: async () => { window.open(this.pdfUrl, '_blank'); } }, { divider: true }, { name: 'Copy PDF URL', iconName: 'lucide:Copy', action: async () => { await navigator.clipboard.writeText(this.pdfUrl); } }, { name: 'Download PDF', iconName: 'lucide:Download', action: async () => { this.downloadPdf(); } }, { name: 'Print PDF', iconName: 'lucide:Printer', action: async () => { this.printPdf(); } } ]; } private get canZoomIn(): boolean { return this.viewportMode !== 'custom' || this.currentZoom < this.MANUAL_MAX_ZOOM; } private get canZoomOut(): boolean { return this.viewportMode !== 'custom' || this.currentZoom > this.MANUAL_MIN_ZOOM; } private ensureViewerRefs() { if (!this.viewerMain) { this.viewerMain = this.shadowRoot?.querySelector('.viewer-main') as HTMLElement; } if (this.viewerMain && !this.resizeObserver) { this.resizeObserver = new ResizeObserver(() => { this.measureViewportDimensions(); if (this.pdfDocument) { this.queueRenderPage(this.currentPage); } }); this.resizeObserver.observe(this.viewerMain); this.measureViewportDimensions(); } } private measureViewportDimensions() { if (!this.viewerMain) { this.viewportDimensions = { width: 0, height: 0 }; return; } const styles = getComputedStyle(this.viewerMain); const paddingX = parseFloat(styles.paddingLeft || '0') + parseFloat(styles.paddingRight || '0'); const paddingY = parseFloat(styles.paddingTop || '0') + parseFloat(styles.paddingBottom || '0'); const width = Math.max(this.viewerMain.clientWidth - paddingX, 0); const height = Math.max(this.viewerMain.clientHeight - paddingY, 0); this.viewportDimensions = { width, height }; } private resolveInitialViewportMode() { if (typeof this.initialZoom === 'number') { this.viewportMode = 'custom'; this.currentZoom = this.normalizeZoom(this.initialZoom, true); } else if (this.initialZoom === 'page-width') { this.viewportMode = 'page-width'; } else if (this.initialZoom === 'page-fit' || this.initialZoom === 'auto') { this.viewportMode = 'page-fit'; } else { this.viewportMode = 'auto'; } if (this.viewportMode !== 'custom') { this.currentZoom = 1; } } private computeViewport(page: any) { this.measureViewportDimensions(); const baseViewport = page.getViewport({ scale: 1 }); let scale: number; switch (this.viewportMode) { case 'page-width': { const availableWidth = this.viewportDimensions.width || baseViewport.width; scale = availableWidth / baseViewport.width; break; } case 'page-fit': case 'auto': { const availableWidth = this.viewportDimensions.width || baseViewport.width; const availableHeight = this.viewportDimensions.height || baseViewport.height; const widthScale = availableWidth / baseViewport.width; const heightScale = availableHeight / baseViewport.height; scale = Math.min(widthScale, heightScale); break; } case 'custom': default: { scale = this.normalizeZoom(this.currentZoom || 1, false); break; } } if (!Number.isFinite(scale) || scale <= 0) { scale = 1; } const clampedScale = this.viewportMode === 'custom' ? this.normalizeZoom(scale, true) : this.normalizeZoom(scale, false); if (this.viewportMode !== 'custom') { this.currentZoom = clampedScale; } return page.getViewport({ scale: clampedScale }); } private normalizeZoom(value: number, clampToManualRange: boolean) { const min = clampToManualRange ? this.MANUAL_MIN_ZOOM : this.ABSOLUTE_MIN_ZOOM; const max = clampToManualRange ? this.MANUAL_MAX_ZOOM : this.ABSOLUTE_MAX_ZOOM; return Math.min(Math.max(value, min), max); } private async cleanupDocument() { // Abort any ongoing render operations if (this.renderAbortController) { this.renderAbortController.abort(); this.renderAbortController = null; } // Wait for any existing render to complete if (this.currentRenderPromise) { try { await this.currentRenderPromise; } catch (error) { // Ignore errors } this.currentRenderPromise = null; } // Clear the render task reference this.currentRenderTask = null; // Cancel any thumbnail render tasks for (const task of (this.thumbnailRenderTasks || [])) { try { task.cancel(); } catch (error) { // Ignore cancellation errors } } this.thumbnailRenderTasks = []; // Reset all state flags this.renderState = 'idle'; this.pageRendering = false; this.pageNumPending = null; this.thumbnailData = []; this.documentId = ''; // Clear canvas content if (this.canvas && this.ctx) { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } // Destroy the document to free memory if (this.pdfDocument) { try { this.pdfDocument.destroy(); } catch (error) { console.error('Error destroying PDF document:', error); } } // Clear the loaded URL reference this.loadedPdfUrl = null; // Finally null the document reference this.pdfDocument = null; // Request update to reflect state changes this.requestUpdate(); } }