538 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			538 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { DeesElement, property, html, customElement, type TemplateResult } from '@design.estate/dees-element';
 | |
| import { PdfManager } from '../dees-pdf-shared/PdfManager.js';
 | |
| import { CanvasPool, type PooledCanvas } from '../dees-pdf-shared/CanvasPool.js';
 | |
| import { PerformanceMonitor, throttle } from '../dees-pdf-shared/utils.js';
 | |
| import { previewStyles } from './styles.js';
 | |
| import { demo as demoFunc } from './demo.js';
 | |
| import '../dees-icon.js';
 | |
| 
 | |
| declare global {
 | |
|   interface HTMLElementTagNameMap {
 | |
|     'dees-pdf-preview': DeesPdfPreview;
 | |
|   }
 | |
| }
 | |
| 
 | |
| @customElement('dees-pdf-preview')
 | |
| export class DeesPdfPreview extends DeesElement {
 | |
|   public static demo = demoFunc;
 | |
|   public static styles = previewStyles;
 | |
| 
 | |
|   @property({ type: String })
 | |
|   public pdfUrl: string = '';
 | |
| 
 | |
|   @property({ type: Number })
 | |
|   public currentPreviewPage: number = 1;
 | |
| 
 | |
|   @property({ type: Boolean })
 | |
|   public clickable: boolean = true;
 | |
| 
 | |
|   @property({ type: Number })
 | |
|   private pageCount: number = 0;
 | |
| 
 | |
|   @property({ type: Boolean })
 | |
|   private loading: boolean = false;
 | |
| 
 | |
|   @property({ type: Boolean })
 | |
|   private rendered: boolean = false;
 | |
| 
 | |
|   @property({ type: Boolean })
 | |
|   private error: boolean = false;
 | |
| 
 | |
|   @property({ type: Boolean })
 | |
|   private isHovering: boolean = false;
 | |
| 
 | |
|   @property({ type: Boolean })
 | |
|   private isA4Format: boolean = true;
 | |
| 
 | |
|   private renderPagesTask: Promise<void> | null = null;
 | |
|   private renderPagesQueued: boolean = false;
 | |
| 
 | |
|   private observer: IntersectionObserver;
 | |
|   private pdfDocument: any;
 | |
|   private canvases: PooledCanvas[] = [];
 | |
|   private resizeObserver?: ResizeObserver;
 | |
|   private previewContainer: HTMLElement | null = null;
 | |
|   private stackElement: HTMLElement | null = null;
 | |
|   private loadedPdfUrl: string | null = null;
 | |
| 
 | |
|   constructor() {
 | |
|     super();
 | |
|   }
 | |
| 
 | |
|   public render(): TemplateResult {
 | |
|     return html`
 | |
|       <div
 | |
|         class="preview-container ${this.loading ? 'loading' : ''} ${this.error ? 'error' : ''} ${this.clickable ? 'clickable' : ''}"
 | |
|         @click=${this.handleClick}
 | |
|         @mouseenter=${this.handleMouseEnter}
 | |
|         @mouseleave=${this.handleMouseLeave}
 | |
|         @mousemove=${this.handleMouseMove}
 | |
|       >
 | |
|         ${this.loading ? html`
 | |
|           <div class="preview-loading">
 | |
|             <div class="preview-spinner"></div>
 | |
|             <div class="preview-text">Loading preview...</div>
 | |
|           </div>
 | |
|         ` : ''}
 | |
| 
 | |
|         ${this.error ? html`
 | |
|           <div class="preview-error">
 | |
|             <dees-icon icon="lucide:FileX"></dees-icon>
 | |
|             <div class="preview-text">Failed to load PDF</div>
 | |
|           </div>
 | |
|         ` : ''}
 | |
| 
 | |
|         ${!this.loading && !this.error ? html`
 | |
|           <div class="preview-stack ${!this.isA4Format ? 'non-a4' : ''}">
 | |
|             <canvas
 | |
|               class="preview-canvas"
 | |
|               data-page="${this.currentPreviewPage}"
 | |
|             ></canvas>
 | |
|           </div>
 | |
| 
 | |
|           ${this.pageCount > 1 && this.isHovering ? html`
 | |
|             <div class="preview-page-indicator">
 | |
|               Page ${this.currentPreviewPage} of ${this.pageCount}
 | |
|             </div>
 | |
|           ` : ''}
 | |
| 
 | |
|           ${this.pageCount > 0 && !this.isHovering ? html`
 | |
|             <div class="preview-info">
 | |
|               <dees-icon icon="lucide:FileText"></dees-icon>
 | |
|               <span class="preview-pages">${this.pageCount} page${this.pageCount > 1 ? 's' : ''}</span>
 | |
|             </div>
 | |
|           ` : ''}
 | |
| 
 | |
|           ${this.clickable ? html`
 | |
|             <div class="preview-overlay">
 | |
|               <dees-icon icon="lucide:Eye"></dees-icon>
 | |
|               <span>View PDF</span>
 | |
|             </div>
 | |
|           ` : ''}
 | |
|         ` : ''}
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
| 
 | |
|   private handleMouseEnter() {
 | |
|     this.isHovering = true;
 | |
|   }
 | |
| 
 | |
|   private handleMouseLeave() {
 | |
|     this.isHovering = false;
 | |
|     // Reset to first page when not hovering
 | |
|     if (this.currentPreviewPage !== 1) {
 | |
|       this.currentPreviewPage = 1;
 | |
|       void this.scheduleRenderPages();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private handleMouseMove(e: MouseEvent) {
 | |
|     if (!this.isHovering || this.pageCount <= 1) return;
 | |
| 
 | |
|     const rect = this.getBoundingClientRect();
 | |
|     const x = e.clientX - rect.left;
 | |
|     const width = rect.width;
 | |
| 
 | |
|     // Calculate which page to show based on horizontal position
 | |
|     const percentage = Math.max(0, Math.min(1, x / width));
 | |
|     const newPage = Math.ceil(percentage * this.pageCount) || 1;
 | |
| 
 | |
|     if (newPage !== this.currentPreviewPage) {
 | |
|       this.currentPreviewPage = newPage;
 | |
|       void this.scheduleRenderPages();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   public async connectedCallback() {
 | |
|     await super.connectedCallback();
 | |
|     this.setupIntersectionObserver();
 | |
|     await this.updateComplete;
 | |
|     this.cacheElements();
 | |
|     this.setupResizeObserver();
 | |
|   }
 | |
| 
 | |
|   public async disconnectedCallback() {
 | |
|     await super.disconnectedCallback();
 | |
|     this.cleanup();
 | |
|     if (this.observer) {
 | |
|       this.observer.disconnect();
 | |
|     }
 | |
|     this.resizeObserver?.disconnect();
 | |
|     this.resizeObserver = undefined;
 | |
|   }
 | |
| 
 | |
|   private setupIntersectionObserver() {
 | |
|     const options = {
 | |
|       root: null,
 | |
|       rootMargin: '200px',
 | |
|       threshold: 0.01,
 | |
|     };
 | |
| 
 | |
|     this.observer = new IntersectionObserver(
 | |
|       throttle((entries) => {
 | |
|         for (const entry of entries) {
 | |
|           if (entry.isIntersecting && !this.rendered && this.pdfUrl) {
 | |
|             this.loadAndRenderPreview();
 | |
|           } else if (!entry.isIntersecting && this.rendered) {
 | |
|             // Optional: Clear canvases when out of view for memory optimization
 | |
|             // this.clearCanvases();
 | |
|           }
 | |
|         }
 | |
|       }, 100),
 | |
|       options
 | |
|     );
 | |
| 
 | |
|     this.observer.observe(this);
 | |
|   }
 | |
| 
 | |
|   private async loadAndRenderPreview() {
 | |
|     if (this.rendered || this.loading) return;
 | |
| 
 | |
|     this.loading = true;
 | |
|     this.error = false;
 | |
|     PerformanceMonitor.mark(`preview-load-${this.pdfUrl}`);
 | |
| 
 | |
|     try {
 | |
|       this.pdfDocument = await PdfManager.loadDocument(this.pdfUrl);
 | |
|       this.pageCount = this.pdfDocument.numPages;
 | |
|       this.currentPreviewPage = 1;
 | |
|       this.loadedPdfUrl = this.pdfUrl;
 | |
| 
 | |
|       // Force an update to ensure the canvas element is in the DOM
 | |
|       this.loading = false;
 | |
|       await this.updateComplete;
 | |
|       this.cacheElements();
 | |
| 
 | |
|       // Now render the first page
 | |
|       await this.scheduleRenderPages();
 | |
| 
 | |
|       this.rendered = true;
 | |
| 
 | |
|       const duration = PerformanceMonitor.measure(`preview-render-${this.pdfUrl}`, `preview-load-${this.pdfUrl}`);
 | |
|       console.log(`PDF preview rendered in ${duration}ms`);
 | |
|     } catch (error) {
 | |
|       console.error('Failed to load PDF preview:', error);
 | |
|       this.error = true;
 | |
|       this.loading = false;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private scheduleRenderPages(): Promise<void> {
 | |
|     if (!this.pdfDocument) {
 | |
|       return Promise.resolve();
 | |
|     }
 | |
| 
 | |
|     if (this.renderPagesTask) {
 | |
|       this.renderPagesQueued = true;
 | |
|       return this.renderPagesTask;
 | |
|     }
 | |
| 
 | |
|     this.renderPagesTask = (async () => {
 | |
|       try {
 | |
|         await this.performRenderPages();
 | |
|       } catch (error) {
 | |
|         console.error('Failed to render PDF preview pages:', error);
 | |
|       }
 | |
|     })().finally(() => {
 | |
|       this.renderPagesTask = null;
 | |
|       if (this.renderPagesQueued) {
 | |
|         this.renderPagesQueued = false;
 | |
|         void this.scheduleRenderPages();
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     return this.renderPagesTask;
 | |
|   }
 | |
| 
 | |
|   private async performRenderPages() {
 | |
|     if (!this.pdfDocument) return;
 | |
| 
 | |
|     // Wait a frame to ensure DOM is ready
 | |
|     await new Promise(resolve => requestAnimationFrame(resolve));
 | |
| 
 | |
|     const canvas = this.shadowRoot?.querySelector('.preview-canvas') as HTMLCanvasElement;
 | |
|     if (!canvas) {
 | |
|       console.warn('Preview canvas not found in DOM');
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // Release old canvases
 | |
|     this.clearCanvases();
 | |
| 
 | |
|     this.cacheElements();
 | |
| 
 | |
|     // Get available size for the preview
 | |
|     const { availableWidth, availableHeight } = this.getAvailableSize();
 | |
| 
 | |
|     try {
 | |
|       // Get the page to render
 | |
|       const pageNum = this.currentPreviewPage;
 | |
|       const page = await this.pdfDocument.getPage(pageNum);
 | |
| 
 | |
|       // Calculate scale to fit within available area while keeping aspect ratio
 | |
|       // Use higher scale for sharper rendering
 | |
|       const initialViewport = page.getViewport({ scale: 1 });
 | |
| 
 | |
|       // Check if this is standard paper format (A4 or US Letter)
 | |
|       const aspectRatio = initialViewport.height / initialViewport.width;
 | |
| 
 | |
|       // Common paper format ratios
 | |
|       const a4PortraitRatio = 1.414; // 297mm / 210mm
 | |
|       const a4LandscapeRatio = 0.707; // 210mm / 297mm
 | |
|       const letterPortraitRatio = 1.294; // 11" / 8.5"
 | |
|       const letterLandscapeRatio = 0.773; // 8.5" / 11"
 | |
| 
 | |
|       // Check for standard formats with 5% tolerance
 | |
|       const tolerance = 0.05;
 | |
|       const isA4Portrait = Math.abs(aspectRatio - a4PortraitRatio) < (a4PortraitRatio * tolerance);
 | |
|       const isA4Landscape = Math.abs(aspectRatio - a4LandscapeRatio) < (a4LandscapeRatio * tolerance);
 | |
|       const isLetterPortrait = Math.abs(aspectRatio - letterPortraitRatio) < (letterPortraitRatio * tolerance);
 | |
|       const isLetterLandscape = Math.abs(aspectRatio - letterLandscapeRatio) < (letterLandscapeRatio * tolerance);
 | |
| 
 | |
|       // Consider it standard format if it matches A4 or US Letter
 | |
|       this.isA4Format = isA4Portrait || isA4Landscape || isLetterPortrait || isLetterLandscape;
 | |
| 
 | |
|       // Debug logging
 | |
|       console.log(`PDF aspect ratio: ${aspectRatio.toFixed(3)}, standard format: ${this.isA4Format}`)
 | |
| 
 | |
|       // Adjust available size for non-A4 documents (account for padding)
 | |
|       const adjustedWidth = this.isA4Format ? availableWidth : availableWidth - 24;
 | |
|       const adjustedHeight = this.isA4Format ? availableHeight : availableHeight - 24;
 | |
| 
 | |
|       const scaleX = adjustedWidth > 0 ? adjustedWidth / initialViewport.width : 0;
 | |
|       const scaleY = adjustedHeight > 0 ? adjustedHeight / initialViewport.height : 0;
 | |
|       // Increase scale by 2x for sharper rendering, but limit to 3.0 max
 | |
|       const baseScale = Math.min(scaleX || 0.5, scaleY || scaleX || 0.5);
 | |
|       const renderScale = Math.min(baseScale * 2, 3.0);
 | |
| 
 | |
|       if (!Number.isFinite(renderScale) || renderScale <= 0) {
 | |
|         page.cleanup?.();
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       const viewport = page.getViewport({ scale: renderScale });
 | |
| 
 | |
|       // Acquire canvas from pool
 | |
|       const pooledCanvas = CanvasPool.acquire(viewport.width, viewport.height);
 | |
|       this.canvases.push(pooledCanvas);
 | |
| 
 | |
|       // Render to pooled canvas first
 | |
|       const renderContext = {
 | |
|         canvasContext: pooledCanvas.ctx,
 | |
|         viewport: viewport,
 | |
|       };
 | |
| 
 | |
|       await page.render(renderContext).promise;
 | |
| 
 | |
|       // Transfer to display canvas
 | |
|       // Set actual canvas resolution for sharpness
 | |
|       canvas.width = viewport.width;
 | |
|       canvas.height = viewport.height;
 | |
| 
 | |
|       // Scale down display size to fit the container while keeping high resolution
 | |
|       // For A4, fill the container; for non-A4, respect padding
 | |
|       const displayWidth = adjustedWidth;
 | |
|       const displayHeight = (viewport.height / viewport.width) * adjustedWidth;
 | |
| 
 | |
|       // If it fits height-wise better, scale by height instead
 | |
|       if (displayHeight > adjustedHeight) {
 | |
|         const altDisplayHeight = adjustedHeight;
 | |
|         const altDisplayWidth = (viewport.width / viewport.height) * adjustedHeight;
 | |
|         canvas.style.width = `${altDisplayWidth}px`;
 | |
|         canvas.style.height = `${altDisplayHeight}px`;
 | |
|       } else {
 | |
|         canvas.style.width = `${displayWidth}px`;
 | |
|         canvas.style.height = `${displayHeight}px`;
 | |
|       }
 | |
| 
 | |
|       const ctx = canvas.getContext('2d');
 | |
|       if (ctx) {
 | |
|         // Enable image smoothing for better quality
 | |
|         ctx.imageSmoothingEnabled = true;
 | |
|         ctx.imageSmoothingQuality = 'high';
 | |
|         ctx.drawImage(pooledCanvas.canvas, 0, 0);
 | |
|       }
 | |
| 
 | |
|       // Release page to free memory
 | |
|       page.cleanup();
 | |
|     } catch (error) {
 | |
|       console.error(`Failed to render page ${this.currentPreviewPage}:`, error);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private clearCanvases() {
 | |
|     // Release pooled canvases
 | |
|     for (const pooledCanvas of this.canvases) {
 | |
|       CanvasPool.release(pooledCanvas);
 | |
|     }
 | |
|     this.canvases = [];
 | |
|   }
 | |
| 
 | |
|   private cleanup() {
 | |
|     this.clearCanvases();
 | |
| 
 | |
|     if (this.pdfDocument) {
 | |
|       PdfManager.releaseDocument(this.loadedPdfUrl ?? this.pdfUrl);
 | |
|       this.pdfDocument = null;
 | |
|     }
 | |
| 
 | |
|     this.renderPagesQueued = false;
 | |
| 
 | |
|     this.pageCount = 0;
 | |
|     this.currentPreviewPage = 1;
 | |
|     this.isHovering = false;
 | |
|     this.isA4Format = true;
 | |
|     this.previewContainer = null;
 | |
|     this.stackElement = null;
 | |
|     this.loadedPdfUrl = null;
 | |
|     this.rendered = false;
 | |
|     this.loading = false;
 | |
|     this.error = false;
 | |
|   }
 | |
| 
 | |
|   private handleClick() {
 | |
|     if (!this.clickable) return;
 | |
| 
 | |
|     // Dispatch custom event for parent to handle
 | |
|     this.dispatchEvent(new CustomEvent('pdf-preview-click', {
 | |
|       detail: {
 | |
|         pdfUrl: this.pdfUrl,
 | |
|         pageCount: this.pageCount,
 | |
|       },
 | |
|       bubbles: true,
 | |
|       composed: true,
 | |
|     }));
 | |
|   }
 | |
| 
 | |
|   public async updated(changedProperties: Map<PropertyKey, unknown>) {
 | |
|     super.updated(changedProperties);
 | |
| 
 | |
|     if (changedProperties.has('pdfUrl') && this.pdfUrl) {
 | |
|       const previousUrl = changedProperties.get('pdfUrl') as string | undefined;
 | |
|       if (previousUrl) {
 | |
|         PdfManager.releaseDocument(previousUrl);
 | |
|       }
 | |
|       this.cleanup();
 | |
|       this.rendered = false;
 | |
|       this.currentPreviewPage = 1;
 | |
| 
 | |
|       // Check if in viewport and render if so
 | |
|       if (this.observer) {
 | |
|         const rect = this.getBoundingClientRect();
 | |
|         if (rect.top < window.innerHeight && rect.bottom > 0) {
 | |
|           this.loadAndRenderPreview();
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (changedProperties.has('currentPreviewPage') && this.rendered) {
 | |
|       await this.scheduleRenderPages();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Provide context menu items for right-click functionality
 | |
|    */
 | |
|   public getContextMenuItems() {
 | |
|     const items: any[] = [];
 | |
| 
 | |
|     // If clickable, add option to view the PDF
 | |
|     if (this.clickable) {
 | |
|       items.push({
 | |
|         name: 'View PDF',
 | |
|         iconName: 'lucide:Eye',
 | |
|         action: async () => {
 | |
|           this.handleClick();
 | |
|         }
 | |
|       });
 | |
|       items.push({ divider: true });
 | |
|     }
 | |
| 
 | |
|     items.push(
 | |
|       {
 | |
|         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 () => {
 | |
|           const link = document.createElement('a');
 | |
|           link.href = this.pdfUrl;
 | |
|           link.download = this.pdfUrl.split('/').pop() || 'document.pdf';
 | |
|           link.click();
 | |
|         }
 | |
|       }
 | |
|     );
 | |
| 
 | |
|     // Add page count info as a disabled item
 | |
|     if (this.pageCount > 0) {
 | |
|       items.push(
 | |
|         { divider: true },
 | |
|         {
 | |
|           name: `${this.pageCount} page${this.pageCount > 1 ? 's' : ''}`,
 | |
|           iconName: 'lucide:FileText',
 | |
|           disabled: true,
 | |
|           action: async () => {}
 | |
|         }
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return items;
 | |
|   }
 | |
| 
 | |
|   private cacheElements() {
 | |
|     if (!this.previewContainer) {
 | |
|       this.previewContainer = this.shadowRoot?.querySelector('.preview-container') as HTMLElement;
 | |
|     }
 | |
|     if (!this.stackElement) {
 | |
|       this.stackElement = this.shadowRoot?.querySelector('.preview-stack') as HTMLElement;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private setupResizeObserver() {
 | |
|     if (!this.previewContainer || this.resizeObserver) return;
 | |
| 
 | |
|     this.resizeObserver = new ResizeObserver(() => {
 | |
|       if (this.rendered && this.pdfDocument && !this.loading) {
 | |
|         void this.scheduleRenderPages();
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     this.resizeObserver.observe(this);
 | |
|   }
 | |
| 
 | |
|   private getAvailableSize() {
 | |
|     if (!this.stackElement) {
 | |
|       // Try to get the stack element if it's not cached
 | |
|       this.stackElement = this.shadowRoot?.querySelector('.preview-stack') as HTMLElement;
 | |
|     }
 | |
| 
 | |
|     if (!this.stackElement) {
 | |
|       // Fallback to default size if element not found
 | |
|       return {
 | |
|         availableWidth: 200,  // Full container width
 | |
|         availableHeight: 260, // Full container height
 | |
|       };
 | |
|     }
 | |
| 
 | |
|     const rect = this.stackElement.getBoundingClientRect();
 | |
|     const availableWidth = Math.max(rect.width, 0) || 200;
 | |
|     const availableHeight = Math.max(rect.height, 0) || 260;
 | |
| 
 | |
|     return { availableWidth, availableHeight };
 | |
|   }
 | |
| }
 |