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 | 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`
${this.loading ? html`
Loading preview...
` : ''} ${this.error ? html`
Failed to load PDF
` : ''} ${!this.loading && !this.error ? html`
${this.pageCount > 1 && this.isHovering ? html`
Page ${this.currentPreviewPage} of ${this.pageCount}
` : ''} ${this.pageCount > 0 && !this.isHovering ? html`
${this.pageCount} page${this.pageCount > 1 ? 's' : ''}
` : ''} ${this.clickable ? html`
View PDF
` : ''} ` : ''}
`; } 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 { 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) { 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 }; } }