import { DeesElement, property, html, customElement, domtools, type TemplateResult, css, cssManager } 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 { DeesContextmenu } from '../dees-contextmenu.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 maxPages: number = 3; @property({ type: Number }) public stackOffset: number = 8; @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; private observer: IntersectionObserver; private pdfDocument: any; private canvases: PooledCanvas[] = []; private renderRequestId: number; 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.getStackedCanvases()}
${this.pageCount > 0 ? html`
${this.pageCount} page${this.pageCount > 1 ? 's' : ''}
` : ''} ${this.clickable ? html`
View PDF
` : ''} ` : ''}
`; } private getStackedCanvases(): TemplateResult[] { const pagesToShow = Math.min(this.pageCount, this.maxPages); const canvases: TemplateResult[] = []; for (let i = pagesToShow - 1; i >= 0; i--) { const offset = i * this.stackOffset; canvases.push(html` `); } return canvases; } public async connectedCallback() { super.connectedCallback(); this.setupIntersectionObserver(); } public async disconnectedCallback() { super.disconnectedCallback(); this.cleanup(); } 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; await this.updateComplete; await this.renderPages(); 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; } finally { this.loading = false; } } private async renderPages() { const canvasElements = this.shadowRoot?.querySelectorAll('.preview-canvas') as NodeListOf; const pagesToRender = Math.min(this.pageCount, this.maxPages); // Release old canvases this.clearCanvases(); // Calculate available width for preview (container width minus padding and stacking offset) const containerWidth = 160; // 200px container - 40px padding const maxStackOffset = (pagesToRender - 1) * this.stackOffset; const availableWidth = containerWidth - maxStackOffset; // Render pages in reverse order (back to front for stacking) for (let i = 0; i < pagesToRender; i++) { const canvas = canvasElements[i]; if (!canvas) continue; const pageNum = parseInt(canvas.dataset.page || '1'); const page = await this.pdfDocument.getPage(pageNum); // Calculate scale to fit within available width const initialViewport = page.getViewport({ scale: 1 }); const scale = Math.min(availableWidth / initialViewport.width, 0.5); // Cap at 0.5 for quality const viewport = page.getViewport({ scale }); // 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 canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = `${viewport.width}px`; canvas.style.height = `${viewport.height}px`; const ctx = canvas.getContext('2d'); if (ctx) { ctx.drawImage(pooledCanvas.canvas, 0, 0); } // Release page to free memory page.cleanup(); } } private clearCanvases() { // Cancel any pending render if (this.renderRequestId) { cancelAnimationFrame(this.renderRequestId); this.renderRequestId = 0; } // Release pooled canvases for (const pooledCanvas of this.canvases) { CanvasPool.release(pooledCanvas); } this.canvases = []; } private cleanup() { if (this.observer) { this.observer.disconnect(); } this.clearCanvases(); if (this.pdfUrl && this.pdfDocument) { PdfManager.releaseDocument(this.pdfUrl); this.pdfDocument = null; } this.rendered = 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) { this.cleanup(); this.rendered = false; // 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(); } } } } /** * 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; } }