import { property, html, customElement, type TemplateResult, type CSSResult } from '@design.estate/dees-element'; import { DeesTileBase } from '../dees-tile-shared/DeesTileBase.js'; import { tileBaseStyles } from '../dees-tile-shared/styles.js'; 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 { tilePdfStyles } from './styles.js'; import { demo as demoFunc } from './demo.js'; declare global { interface HTMLElementTagNameMap { 'dees-tile-pdf': DeesTilePdf; } } @customElement('dees-tile-pdf') export class DeesTilePdf extends DeesTileBase { public static demo = demoFunc; public static demoGroups = ['Media', 'PDF']; public static styles = [...tileBaseStyles, tilePdfStyles] as any; @property({ type: String }) accessor pdfUrl: string = ''; @property({ type: Number }) accessor currentPreviewPage: number = 1; @property({ type: Number }) accessor pageCount: number = 0; @property({ type: Boolean }) accessor rendered: boolean = false; @property({ type: Boolean }) accessor isHovering: boolean = false; @property({ type: Boolean }) accessor isA4Format: boolean = true; private renderPagesTask: Promise | null = null; private renderPagesQueued: boolean = false; private pdfDocument: any; private canvases: PooledCanvas[] = []; private resizeObserver?: ResizeObserver; private stackElement: HTMLElement | null = null; private loadedPdfUrl: string | null = null; protected renderTileContent(): TemplateResult { return 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
` : ''} `; } protected getTileClickDetail(): Record { return { pdfUrl: this.pdfUrl, pageCount: this.pageCount, }; } protected onBecameVisible(): void { if (!this.rendered && this.pdfUrl) { this.loadAndRenderPreview(); } } protected onTileMouseEnter(): void { this.isHovering = true; } protected onTileMouseLeave(): void { this.isHovering = false; if (this.currentPreviewPage !== 1) { this.currentPreviewPage = 1; void this.scheduleRenderPages(); } } protected onTileMouseMove(e: MouseEvent): void { if (!this.isHovering || this.pageCount <= 1) return; const rect = this.getBoundingClientRect(); const x = e.clientX - rect.left; const width = rect.width; 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(): Promise { await super.connectedCallback(); await this.updateComplete; this.cacheElements(); this.setupResizeObserver(); } public async disconnectedCallback(): Promise { await super.disconnectedCallback(); this.cleanup(); this.resizeObserver?.disconnect(); this.resizeObserver = undefined; } private async loadAndRenderPreview(): Promise { 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; this.loading = false; 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 tile rendered in ${duration}ms`); } catch (error) { console.error('Failed to load PDF tile:', 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 tile pages:', error); } })().finally(() => { this.renderPagesTask = null; if (this.renderPagesQueued) { this.renderPagesQueued = false; void this.scheduleRenderPages(); } }); return this.renderPagesTask; } private async performRenderPages(): Promise { if (!this.pdfDocument) return; await new Promise(resolve => requestAnimationFrame(resolve)); const canvas = this.shadowRoot?.querySelector('.preview-canvas') as HTMLCanvasElement; if (!canvas) return; this.clearCanvases(); this.cacheElements(); const { availableWidth, availableHeight } = this.getAvailableSize(); try { const pageNum = this.currentPreviewPage; const page = await this.pdfDocument.getPage(pageNum); const initialViewport = page.getViewport({ scale: 1 }); const aspectRatio = initialViewport.height / initialViewport.width; const a4PortraitRatio = 1.414; const a4LandscapeRatio = 0.707; const letterPortraitRatio = 1.294; const letterLandscapeRatio = 0.773; 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); this.isA4Format = isA4Portrait || isA4Landscape || isLetterPortrait || isLetterLandscape; 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; 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 }); const pooledCanvas = CanvasPool.acquire(viewport.width, viewport.height); this.canvases.push(pooledCanvas); const renderContext = { canvasContext: pooledCanvas.ctx, viewport: viewport, }; await page.render(renderContext).promise; canvas.width = viewport.width; canvas.height = viewport.height; const displayWidth = adjustedWidth; const displayHeight = (viewport.height / viewport.width) * adjustedWidth; 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) { ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(pooledCanvas.canvas, 0, 0); } page.cleanup(); } catch (error) { console.error(`Failed to render page ${this.currentPreviewPage}:`, error); } } private clearCanvases(): void { for (const pooledCanvas of this.canvases) { CanvasPool.release(pooledCanvas); } this.canvases = []; } private cleanup(): void { 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.stackElement = null; this.loadedPdfUrl = null; this.rendered = false; this.loading = false; this.error = false; } public async updated(changedProperties: Map): Promise { 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; const rect = this.getBoundingClientRect(); if (rect.top < window.innerHeight && rect.bottom > 0) { this.loadAndRenderPreview(); } } if (changedProperties.has('currentPreviewPage') && this.rendered) { await this.scheduleRenderPages(); } } public getContextMenuItems(): any[] { const items: any[] = []; if (this.clickable) { items.push({ name: 'View PDF', iconName: 'lucide:Eye', action: async () => { this.dispatchEvent(new CustomEvent('tile-click', { detail: this.getTileClickDetail(), bubbles: true, composed: true, })); } }); 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(); } } ); 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(): void { if (!this.stackElement) { this.stackElement = this.shadowRoot?.querySelector('.preview-stack') as HTMLElement; } } private setupResizeObserver(): void { if (this.resizeObserver) return; this.resizeObserver = new ResizeObserver(() => { if (this.rendered && this.pdfDocument && !this.loading) { void this.scheduleRenderPages(); } }); this.resizeObserver.observe(this); } private getAvailableSize(): { availableWidth: number; availableHeight: number } { if (!this.stackElement) { this.stackElement = this.shadowRoot?.querySelector('.preview-stack') as HTMLElement; } if (!this.stackElement) { return { availableWidth: 200, availableHeight: 260 }; } const rect = this.stackElement.getBoundingClientRect(); const availableWidth = Math.max(rect.width, 0) || 200; const availableHeight = Math.max(rect.height, 0) || 260; return { availableWidth, availableHeight }; } }