import { DeesElement, property, html, customElement, type TemplateResult } from '@design.estate/dees-element'; import { keyed } from 'lit/directives/keyed.js'; import { repeat } from 'lit/directives/repeat.js'; import { PdfManager } from '../dees-pdf-shared/PdfManager.js'; import { viewerStyles } from './styles.js'; import { demo as demoFunc } from './demo.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}> = []; @property({ type: Array }) private pageData: Array<{page: number, rendered: boolean, rendering: 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 pageRenderTasks: Map = new Map(); private canvas: HTMLCanvasElement | undefined; private ctx: CanvasRenderingContext2D | undefined; private viewerMain: HTMLElement | null = null; private resizeObserver?: ResizeObserver; private intersectionObserver?: IntersectionObserver; private scrollThrottleTimeout?: number; private viewportDimensions = { width: 0, height: 0 }; private viewportMode: 'auto' | 'page-fit' | 'page-width' | 'custom' = 'auto'; 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; private readonly PAGE_GAP = 20; private readonly RENDER_BUFFER = 3; constructor() { super(); } public render(): TemplateResult { return html`
${this.showToolbar ? html`
/ ${this.totalPages}
` : ''}
${this.showSidebar ? html` ` : ''}
${this.loading ? html`
Loading PDF...
` : html`
${repeat( this.pageData, (item) => item.page, (item) => 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; this.intersectionObserver?.disconnect(); this.intersectionObserver = undefined; // Clear scroll timeout if (this.scrollThrottleTimeout) { clearTimeout(this.scrollThrottleTimeout); this.scrollThrottleTimeout = 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(); } // Re-render thumbnails when sidebar becomes visible and document is loaded if (changedProperties.has('showSidebar') && this.showSidebar && this.pdfDocument) { // Use requestAnimationFrame to ensure DOM is ready await new Promise(resolve => requestAnimationFrame(resolve)); // Force re-render of thumbnails by resetting their rendered state this.thumbnailData.forEach(thumb => thumb.rendered = false); await this.renderThumbnails(); // Re-setup intersection observer for lazy loading of pages this.setupIntersectionObserver(); } } 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 and page data arrays this.thumbnailData = Array.from({length: this.totalPages}, (_, i) => ({ page: i + 1, rendered: false })); this.pageData = Array.from({length: this.totalPages}, (_, i) => ({ page: i + 1, rendered: false, rendering: false })); // Set loading to false to render the pages this.loading = false; await this.updateComplete; this.ensureViewerRefs(); this.setupIntersectionObserver(); // Wait for next frame to ensure DOM is ready await new Promise(resolve => requestAnimationFrame(resolve)); if (signal.aborted) return; this.renderState = 'rendering-main'; // Render initial visible pages await this.renderVisiblePages(); if (signal.aborted) return; // Scroll to initial page if (this.initialPage > 1) { await this.scrollToPage(this.initialPage, false); } 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'; } catch (error) { console.error('Error loading PDF:', error); this.loading = false; this.renderState = 'error'; } } private setupIntersectionObserver() { if (this.intersectionObserver) { this.intersectionObserver.disconnect(); } this.intersectionObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { const pageWrapper = entry.target as HTMLElement; const pageNum = parseInt(pageWrapper.dataset.page || '1'); if (entry.isIntersecting) { this.renderPageIfNeeded(pageNum); } } }, { root: this.viewerMain, rootMargin: `${this.RENDER_BUFFER * 100}px 0px`, threshold: 0.01 } ); // Observe all page wrappers const pageWrappers = this.shadowRoot?.querySelectorAll('.page-wrapper'); if (pageWrappers) { pageWrappers.forEach(wrapper => { this.intersectionObserver?.observe(wrapper); }); } } private async renderVisiblePages() { if (!this.viewerMain) return; // Find visible pages based on scroll position const clientHeight = this.viewerMain.clientHeight; for (const pageInfo of this.pageData) { const pageWrapper = this.shadowRoot?.querySelector(`.page-wrapper[data-page="${pageInfo.page}"]`) as HTMLElement; if (!pageWrapper) continue; const rect = pageWrapper.getBoundingClientRect(); const viewerRect = this.viewerMain.getBoundingClientRect(); const relativeTop = rect.top - viewerRect.top; const relativeBottom = relativeTop + rect.height; // Check if page is visible or within buffer zone const buffer = this.RENDER_BUFFER * clientHeight; if (relativeBottom >= -buffer && relativeTop <= clientHeight + buffer) { await this.renderPageIfNeeded(pageInfo.page); } } } private async renderPageIfNeeded(pageNum: number) { const pageInfo = this.pageData.find(p => p.page === pageNum); if (!pageInfo || pageInfo.rendered || pageInfo.rendering) return; pageInfo.rendering = true; try { const canvas = this.shadowRoot?.querySelector(`.page-canvas[data-page="${pageNum}"]`) as HTMLCanvasElement; if (!canvas) { pageInfo.rendering = false; return; } const page = await this.pdfDocument.getPage(pageNum); const viewport = this.computeViewport(page); // Set canvas dimensions canvas.height = viewport.height; canvas.width = viewport.width; canvas.style.width = `${viewport.width}px`; canvas.style.height = `${viewport.height}px`; const ctx = canvas.getContext('2d'); if (!ctx) { page.cleanup?.(); pageInfo.rendering = false; return; } const renderContext = { canvasContext: ctx, viewport: viewport, }; const renderTask = page.render(renderContext); this.pageRenderTasks.set(pageNum, renderTask); await renderTask.promise; page.cleanup?.(); pageInfo.rendered = true; pageInfo.rendering = false; this.pageRenderTasks.delete(pageNum); // Update page data to reflect rendered state this.requestUpdate('pageData'); } catch (error: any) { if (error?.name !== 'RenderingCancelledException') { console.error(`Error rendering page ${pageNum}:`, error); } pageInfo.rendering = false; this.pageRenderTasks.delete(pageNum); } } private handleScroll = () => { // Throttle scroll events if (this.scrollThrottleTimeout) { clearTimeout(this.scrollThrottleTimeout); } this.scrollThrottleTimeout = window.setTimeout(() => { this.updateCurrentPage(); this.renderVisiblePages(); }, 50); } private updateCurrentPage() { if (!this.viewerMain) return; const scrollTop = this.viewerMain.scrollTop; const clientHeight = this.viewerMain.clientHeight; const centerY = scrollTop + clientHeight / 2; // Find which page is at the center of the viewport for (let i = 0; i < this.pageData.length; i++) { const pageWrapper = this.shadowRoot?.querySelector(`.page-wrapper[data-page="${i + 1}"]`) as HTMLElement; if (!pageWrapper) continue; const rect = pageWrapper.getBoundingClientRect(); const viewerRect = this.viewerMain.getBoundingClientRect(); const relativeTop = rect.top - viewerRect.top + scrollTop; const relativeBottom = relativeTop + rect.height; if (centerY >= relativeTop && centerY <= relativeBottom) { if (this.currentPage !== i + 1) { this.currentPage = i + 1; // Scroll the thumbnail into view if sidebar is visible if (this.showSidebar) { this.scrollThumbnailIntoView(i + 1); } } break; } } } private scrollThumbnailIntoView(pageNum: number) { const thumbnail = this.shadowRoot?.querySelector(`.thumbnail[data-page="${pageNum}"]`) as HTMLElement; const sidebarContent = this.shadowRoot?.querySelector('.sidebar-content') as HTMLElement; if (thumbnail && sidebarContent) { // Get the thumbnail's position relative to the sidebar const thumbnailRect = thumbnail.getBoundingClientRect(); const sidebarRect = sidebarContent.getBoundingClientRect(); // Check if thumbnail is outside the visible area const isAbove = thumbnailRect.top < sidebarRect.top; const isBelow = thumbnailRect.bottom > sidebarRect.bottom; if (isAbove || isBelow) { // Calculate the scroll position to center the thumbnail const thumbnailOffset = thumbnail.offsetTop; const thumbnailHeight = thumbnail.offsetHeight; const sidebarHeight = sidebarContent.clientHeight; const targetScrollTop = thumbnailOffset - (sidebarHeight / 2) + (thumbnailHeight / 2); // Scroll the sidebar to center the thumbnail sidebarContent.scrollTo({ top: Math.max(0, targetScrollTop), behavior: 'smooth' }); } } } private async scrollToPage(pageNum: number, smooth: boolean = true) { await this.updateComplete; const pageWrapper = this.shadowRoot?.querySelector(`.page-wrapper[data-page="${pageNum}"]`) as HTMLElement; if (pageWrapper && this.viewerMain) { // Calculate the offset of the page wrapper relative to the viewer const pageRect = pageWrapper.getBoundingClientRect(); const viewerRect = this.viewerMain.getBoundingClientRect(); const currentScrollTop = this.viewerMain.scrollTop; // Calculate the target scroll position const targetScrollTop = currentScrollTop + (pageRect.top - viewerRect.top) - this.viewerMain.clientTop; // Scroll to the calculated position if (smooth) { this.viewerMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); } else { this.viewerMain.scrollTop = targetScrollTop; } // Update current page this.currentPage = pageNum; // Ensure the page is rendered await this.renderPageIfNeeded(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') as NodeListOf; const thumbnailCanvases = this.shadowRoot?.querySelectorAll('.thumbnail-canvas') as NodeListOf; const sidebarContent = this.shadowRoot?.querySelector('.sidebar-content') as HTMLElement; // Get the actual available width for thumbnails (sidebar width minus padding) const sidebarStyles = window.getComputedStyle(sidebarContent); const sidebarPadding = parseFloat(sidebarStyles.paddingLeft) + parseFloat(sidebarStyles.paddingRight); const maxThumbnailWidth = 200 - sidebarPadding - 4; // Account for border // Clear all canvases first to prevent conflicts for (const canvas of Array.from(thumbnailCanvases)) { const context = canvas.getContext('2d'); if (context) { context.clearRect(0, 0, canvas.width, canvas.height); } } for (let i = 0; i < thumbnailCanvases.length; i++) { if (signal?.aborted) return; const canvas = thumbnailCanvases[i]; const thumbnail = thumbnails[i]; const pageNum = parseInt(canvas.dataset.page || '1'); const page = await this.pdfDocument.getPage(pageNum); // Get the page's natural dimensions const initialViewport = page.getViewport({ scale: 1 }); // Calculate scale to fit within the max thumbnail width const scale = maxThumbnailWidth / initialViewport.width; const viewport = page.getViewport({ scale }); // Set canvas dimensions to actual render size canvas.width = viewport.width; canvas.height = viewport.height; // Set the display size via style to ensure proper display canvas.style.width = `${viewport.width}px`; canvas.style.height = `${viewport.height}px`; // Set the actual thumbnail container height thumbnail.style.height = `${viewport.height}px`; thumbnail.style.minHeight = `${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.scrollToPage(this.currentPage - 1); } } private nextPage() { if (this.currentPage < this.totalPages) { this.scrollToPage(this.currentPage + 1); } } private handleThumbnailClick(e: Event) { const target = e.currentTarget as HTMLElement; const pageNum = parseInt(target.dataset.page || '1'); this.scrollToPage(pageNum); } private handlePageInput(e: Event) { const input = e.target as HTMLInputElement; const pageNum = parseInt(input.value); this.scrollToPage(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.reRenderAllPages(); } } 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.reRenderAllPages(); } } private resetZoom() { this.viewportMode = 'custom'; this.currentZoom = 1; this.reRenderAllPages(); } private fitToPage() { this.viewportMode = 'page-fit'; this.reRenderAllPages(); } private fitToWidth() { this.viewportMode = 'page-width'; this.reRenderAllPages(); } private reRenderAllPages() { // Clear all rendered pages to force re-render with new zoom this.pageData.forEach(page => { page.rendered = false; page.rendering = false; }); // Cancel any ongoing render tasks this.pageRenderTasks.forEach(task => { try { task.cancel(); } catch (error) { // Ignore cancellation errors } }); this.pageRenderTasks.clear(); // Request update to re-render pages this.requestUpdate(); // Render visible pages after update this.updateComplete.then(() => { this.renderVisiblePages(); }); } 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) { // Re-render all pages when viewport size changes this.reRenderAllPages(); } }); this.resizeObserver.observe(this.viewerMain); this.measureViewportDimensions(); // Prevent scroll propagation to parent when scrolling inside viewer this.viewerMain.addEventListener('wheel', (e) => { const element = e.currentTarget as HTMLElement; const scrollTop = element.scrollTop; const scrollHeight = element.scrollHeight; const clientHeight = element.clientHeight; const deltaY = e.deltaY; // Check if we're at the boundaries const isAtTop = scrollTop === 0; const isAtBottom = Math.abs(scrollTop + clientHeight - scrollHeight) < 1; // Prevent propagation if we're scrolling within bounds if ((deltaY < 0 && !isAtTop) || (deltaY > 0 && !isAtBottom)) { e.stopPropagation(); } else if ((deltaY < 0 && isAtTop) || (deltaY > 0 && isAtBottom)) { // Prevent default and propagation when at boundaries e.preventDefault(); e.stopPropagation(); } }, { passive: false }); } } 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 page render tasks this.pageRenderTasks.forEach(task => { try { task.cancel(); } catch (error) { // Ignore cancellation errors } }); this.pageRenderTasks.clear(); // 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.pageData = []; 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); } } // Finally null the document reference this.pdfDocument = null; // Request update to reflect state changes this.requestUpdate(); } }