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 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.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() { 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.loadedPdfUrl = this.pdfUrl; await this.updateComplete; this.cacheElements(); 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; } finally { 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; const canvasElements = this.shadowRoot?.querySelectorAll('.preview-canvas') as NodeListOf; const pagesToRender = Math.min(this.pageCount, this.maxPages); // Release old canvases this.clearCanvases(); const maxStackOffset = (pagesToRender - 1) * this.stackOffset; this.cacheElements(); const { availableWidth, availableHeight } = this.getAvailableStackSize(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 area while keeping aspect ratio const initialViewport = page.getViewport({ scale: 1 }); const scaleX = availableWidth > 0 ? availableWidth / initialViewport.width : 0; const scaleY = availableHeight > 0 ? availableHeight / initialViewport.height : 0; const scale = Math.min(scaleX || 0.5, scaleY || scaleX || 0.5, 0.75); if (!Number.isFinite(scale) || scale <= 0) { page.cleanup?.(); continue; } 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() { // 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.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; // 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('maxPages') || changedProperties.has('stackOffset')) && 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 getAvailableStackSize(maxStackOffset: number) { if (!this.stackElement) { return { availableWidth: 0, availableHeight: 0, }; } const rect = this.stackElement.getBoundingClientRect(); const availableWidth = Math.max(rect.width - maxStackOffset, 0); const availableHeight = Math.max(rect.height - maxStackOffset, 0); return { availableWidth, availableHeight }; } }