From a61f57db13a4dcf0fc7034223a302b1625818901 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 20 Sep 2025 11:42:22 +0000 Subject: [PATCH] feat: Add PDF viewer and preview components with styling and functionality - Implemented DeesPdfViewer for full-featured PDF viewing with toolbar and sidebar navigation. - Created DeesPdfPreview for lightweight PDF previews. - Introduced PdfManager for managing PDF document loading and caching. - Added CanvasPool for efficient canvas management. - Developed utility functions for performance monitoring and file size formatting. - Established styles for viewer and preview components to enhance UI/UX. - Included demo examples for showcasing PDF viewer capabilities. --- ts_web/elements/dees-pdf-preview/component.ts | 290 +++++++++++++ ts_web/elements/dees-pdf-preview/demo.ts | 189 +++++++++ ts_web/elements/dees-pdf-preview/index.ts | 1 + ts_web/elements/dees-pdf-preview/styles.ts | 177 ++++++++ ts_web/elements/dees-pdf-shared/CanvasPool.ts | 135 ++++++ ts_web/elements/dees-pdf-shared/PdfManager.ts | 108 +++++ ts_web/elements/dees-pdf-shared/utils.ts | 98 +++++ ts_web/elements/dees-pdf-viewer/component.ts | 386 ++++++++++++++++++ ts_web/elements/dees-pdf-viewer/demo.ts | 69 ++++ ts_web/elements/dees-pdf-viewer/index.ts | 1 + ts_web/elements/dees-pdf-viewer/styles.ts | 262 ++++++++++++ .../{dees-pdf.ts => dees-pdf/component.ts} | 7 +- ts_web/elements/dees-pdf/index.ts | 1 + ts_web/elements/index.ts | 4 +- 14 files changed, 1726 insertions(+), 2 deletions(-) create mode 100644 ts_web/elements/dees-pdf-preview/component.ts create mode 100644 ts_web/elements/dees-pdf-preview/demo.ts create mode 100644 ts_web/elements/dees-pdf-preview/index.ts create mode 100644 ts_web/elements/dees-pdf-preview/styles.ts create mode 100644 ts_web/elements/dees-pdf-shared/CanvasPool.ts create mode 100644 ts_web/elements/dees-pdf-shared/PdfManager.ts create mode 100644 ts_web/elements/dees-pdf-shared/utils.ts create mode 100644 ts_web/elements/dees-pdf-viewer/component.ts create mode 100644 ts_web/elements/dees-pdf-viewer/demo.ts create mode 100644 ts_web/elements/dees-pdf-viewer/index.ts create mode 100644 ts_web/elements/dees-pdf-viewer/styles.ts rename ts_web/elements/{dees-pdf.ts => dees-pdf/component.ts} (93%) create mode 100644 ts_web/elements/dees-pdf/index.ts diff --git a/ts_web/elements/dees-pdf-preview/component.ts b/ts_web/elements/dees-pdf-preview/component.ts new file mode 100644 index 0000000..789fb19 --- /dev/null +++ b/ts_web/elements/dees-pdf-preview/component.ts @@ -0,0 +1,290 @@ +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'; + +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(); + } + } + } + } +} \ No newline at end of file diff --git a/ts_web/elements/dees-pdf-preview/demo.ts b/ts_web/elements/dees-pdf-preview/demo.ts new file mode 100644 index 0000000..a2707e4 --- /dev/null +++ b/ts_web/elements/dees-pdf-preview/demo.ts @@ -0,0 +1,189 @@ +import { html } from '@design.estate/dees-element'; + +export const demo = () => { + const samplePdfs = [ + 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf', + 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf', + ]; + + const generateGridItems = (count: number) => { + const items = []; + for (let i = 0; i < count; i++) { + const pdfUrl = samplePdfs[i % samplePdfs.length]; + items.push(html` + { + console.log('PDF Preview clicked:', e.detail); + alert(`PDF clicked: ${e.detail.pageCount} pages`); + }} + > + `); + } + return items; + }; + + return html` + + +
+
+

Single PDF Preview with Stacked Pages

+ +
+ +
+

Different Sizes

+
+
Small:
+ +
+ +
+
Default:
+ +
+ +
+
Large:
+ +
+
+ +
+

Non-Clickable Preview

+ +
+ +
+

Performance Grid - 50 PDFs with Lazy Loading

+

+ This grid demonstrates the performance optimizations with 50 PDF previews. + Scroll to see lazy loading in action - previews render only when visible. +

+ +
+ ${generateGridItems(50)} +
+ +
+

Performance Features

+
+
+ Lazy Loading + ✓ Enabled +
+
+ Canvas Pooling + ✓ Active +
+
+ Memory Management + ✓ Optimized +
+
+ Intersection Observer + 200px margin +
+
+
+
+
+ `; +}; \ No newline at end of file diff --git a/ts_web/elements/dees-pdf-preview/index.ts b/ts_web/elements/dees-pdf-preview/index.ts new file mode 100644 index 0000000..9455f54 --- /dev/null +++ b/ts_web/elements/dees-pdf-preview/index.ts @@ -0,0 +1 @@ +export * from './component.js'; \ No newline at end of file diff --git a/ts_web/elements/dees-pdf-preview/styles.ts b/ts_web/elements/dees-pdf-preview/styles.ts new file mode 100644 index 0000000..ad03c3a --- /dev/null +++ b/ts_web/elements/dees-pdf-preview/styles.ts @@ -0,0 +1,177 @@ +import { css, cssManager } from '@design.estate/dees-element'; + +export const previewStyles = [ + cssManager.defaultStyles, + css` + :host { + display: inline-block; + position: relative; + } + + .preview-container { + position: relative; + width: 200px; + height: 260px; + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 16%)')}; + border: 1px solid ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 26%)')}; + border-radius: 12px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + + .preview-container.clickable { + cursor: pointer; + } + + .preview-container.clickable:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px ${cssManager.bdTheme('rgba(0, 0, 0, 0.12)', 'rgba(0, 0, 0, 0.3)')}; + } + + .preview-container.clickable:hover .preview-overlay { + opacity: 1; + } + + .preview-stack { + position: relative; + width: 100%; + height: 100%; + padding: 20px; + box-sizing: border-box; + } + + .preview-canvas { + position: absolute; + background: white; + border: 1px solid ${cssManager.bdTheme('hsl(214 31% 88%)', 'hsl(217 25% 30%)')}; + border-radius: 4px; + display: block; + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + } + + .preview-info { + position: absolute; + bottom: 12px; + left: 12px; + right: 12px; + padding: 8px 12px; + background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.95)', 'hsl(215 20% 12% / 0.95)')}; + border: 1px solid ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 26%)')}; + border-radius: 8px; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')}; + backdrop-filter: blur(8px); + z-index: 10; + } + + .preview-info dees-icon { + font-size: 14px; + color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; + } + + .preview-pages { + font-weight: 500; + } + + .preview-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.7)', 'rgba(0, 0, 0, 0.8)')}; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 20; + } + + .preview-overlay dees-icon { + font-size: 24px; + color: white; + } + + .preview-overlay span { + font-size: 14px; + font-weight: 500; + color: white; + } + + .preview-loading, + .preview-error { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')}; + } + + .preview-loading { + background: ${cssManager.bdTheme('hsl(0 0% 99%)', 'hsl(215 20% 14%)')}; + } + + .preview-error { + background: ${cssManager.bdTheme('hsl(0 72% 98%)', 'hsl(0 62% 20%)')}; + color: ${cssManager.bdTheme('hsl(0 72% 40%)', 'hsl(0 70% 68%)')}; + } + + .preview-spinner { + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 28%)')}; + border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + .preview-text { + font-size: 13px; + font-weight: 500; + } + + .preview-error dees-icon { + font-size: 32px; + } + + /* Responsive sizes */ + :host([size="small"]) .preview-container { + width: 150px; + height: 195px; + } + + :host([size="large"]) .preview-container { + width: 250px; + height: 325px; + } + + /* Grid optimizations */ + :host([grid-mode]) .preview-container { + will-change: auto; + } + + :host([grid-mode]) .preview-canvas { + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + } + `, +]; \ No newline at end of file diff --git a/ts_web/elements/dees-pdf-shared/CanvasPool.ts b/ts_web/elements/dees-pdf-shared/CanvasPool.ts new file mode 100644 index 0000000..51c6afc --- /dev/null +++ b/ts_web/elements/dees-pdf-shared/CanvasPool.ts @@ -0,0 +1,135 @@ +export interface PooledCanvas { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + inUse: boolean; + lastUsed: number; +} + +export class CanvasPool { + private static pool: PooledCanvas[] = []; + private static maxPoolSize = 20; + private static readonly MIN_CANVAS_SIZE = 256; + private static readonly MAX_CANVAS_SIZE = 4096; + + public static acquire(width: number, height: number): PooledCanvas { + // Try to find a suitable canvas from the pool + const suitable = this.pool.find( + (item) => !item.inUse && + item.canvas.width >= width && + item.canvas.height >= height && + item.canvas.width <= width * 1.5 && + item.canvas.height <= height * 1.5 + ); + + if (suitable) { + suitable.inUse = true; + suitable.lastUsed = Date.now(); + + // Clear and resize if needed + suitable.canvas.width = width; + suitable.canvas.height = height; + suitable.ctx.clearRect(0, 0, width, height); + + return suitable; + } + + // Create new canvas if pool not full + if (this.pool.length < this.maxPoolSize) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { + alpha: true, + desynchronized: true, + }) as CanvasRenderingContext2D; + + canvas.width = Math.min(Math.max(width, this.MIN_CANVAS_SIZE), this.MAX_CANVAS_SIZE); + canvas.height = Math.min(Math.max(height, this.MIN_CANVAS_SIZE), this.MAX_CANVAS_SIZE); + + const pooledCanvas: PooledCanvas = { + canvas, + ctx, + inUse: true, + lastUsed: Date.now(), + }; + + this.pool.push(pooledCanvas); + return pooledCanvas; + } + + // Evict and reuse least recently used canvas + const lru = this.pool + .filter((item) => !item.inUse) + .sort((a, b) => a.lastUsed - b.lastUsed)[0]; + + if (lru) { + lru.canvas.width = width; + lru.canvas.height = height; + lru.ctx.clearRect(0, 0, width, height); + lru.inUse = true; + lru.lastUsed = Date.now(); + return lru; + } + + // Fallback: create temporary canvas (shouldn't normally happen) + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + canvas.width = width; + canvas.height = height; + + return { + canvas, + ctx, + inUse: true, + lastUsed: Date.now(), + }; + } + + public static release(pooledCanvas: PooledCanvas) { + if (this.pool.includes(pooledCanvas)) { + pooledCanvas.inUse = false; + // Clear canvas to free memory + pooledCanvas.ctx.clearRect(0, 0, pooledCanvas.canvas.width, pooledCanvas.canvas.height); + } + } + + public static releaseAll() { + for (const item of this.pool) { + item.inUse = false; + item.ctx.clearRect(0, 0, item.canvas.width, item.canvas.height); + } + } + + public static destroy() { + for (const item of this.pool) { + item.canvas.width = 0; + item.canvas.height = 0; + } + this.pool = []; + } + + public static getStats() { + return { + poolSize: this.pool.length, + maxPoolSize: this.maxPoolSize, + inUse: this.pool.filter((item) => item.inUse).length, + available: this.pool.filter((item) => !item.inUse).length, + }; + } + + public static adjustPoolSize(newSize: number) { + if (newSize < this.pool.length) { + // Remove excess canvases + const toRemove = this.pool.length - newSize; + const removed = this.pool + .filter((item) => !item.inUse) + .slice(0, toRemove); + + for (const item of removed) { + const index = this.pool.indexOf(item); + if (index > -1) { + this.pool.splice(index, 1); + } + } + } + this.maxPoolSize = newSize; + } +} \ No newline at end of file diff --git a/ts_web/elements/dees-pdf-shared/PdfManager.ts b/ts_web/elements/dees-pdf-shared/PdfManager.ts new file mode 100644 index 0000000..02ce4a4 --- /dev/null +++ b/ts_web/elements/dees-pdf-shared/PdfManager.ts @@ -0,0 +1,108 @@ +import { domtools } from '@design.estate/dees-element'; + +interface CachedDocument { + url: string; + document: any; + lastAccessed: number; + refCount: number; +} + +export class PdfManager { + private static cache = new Map(); + private static maxCacheSize = 10; + private static pdfjsLib: any; + private static initialized = false; + + public static async initialize() { + if (this.initialized) return; + + // @ts-ignore + this.pdfjsLib = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4.0.379/+esm'); + this.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.0.379/build/pdf.worker.mjs'; + + this.initialized = true; + } + + public static async loadDocument(url: string): Promise { + await this.initialize(); + + // Check cache first + const cached = this.cache.get(url); + if (cached) { + cached.lastAccessed = Date.now(); + cached.refCount++; + return cached.document; + } + + // Load new document + const loadingTask = this.pdfjsLib.getDocument(url); + const document = await loadingTask.promise; + + // Add to cache with LRU eviction if needed + if (this.cache.size >= this.maxCacheSize) { + this.evictLeastRecentlyUsed(); + } + + this.cache.set(url, { + url, + document, + lastAccessed: Date.now(), + refCount: 1, + }); + + return document; + } + + public static releaseDocument(url: string) { + const cached = this.cache.get(url); + if (cached) { + cached.refCount--; + if (cached.refCount <= 0) { + // Don't immediately remove, keep for potential reuse + cached.refCount = 0; + } + } + } + + private static evictLeastRecentlyUsed() { + let oldestTime = Infinity; + let oldestKey: string | null = null; + + for (const [key, value] of this.cache.entries()) { + // Only evict if not currently in use + if (value.refCount === 0 && value.lastAccessed < oldestTime) { + oldestTime = value.lastAccessed; + oldestKey = key; + } + } + + if (oldestKey) { + const cached = this.cache.get(oldestKey); + if (cached?.document) { + cached.document.destroy?.(); + } + this.cache.delete(oldestKey); + } + } + + public static clearCache() { + for (const cached of this.cache.values()) { + if (cached.document) { + cached.document.destroy?.(); + } + } + this.cache.clear(); + } + + public static getCacheStats() { + return { + size: this.cache.size, + maxSize: this.maxCacheSize, + entries: Array.from(this.cache.entries()).map(([url, data]) => ({ + url, + refCount: data.refCount, + lastAccessed: new Date(data.lastAccessed).toISOString(), + })), + }; + } +} \ No newline at end of file diff --git a/ts_web/elements/dees-pdf-shared/utils.ts b/ts_web/elements/dees-pdf-shared/utils.ts new file mode 100644 index 0000000..2624a1f --- /dev/null +++ b/ts_web/elements/dees-pdf-shared/utils.ts @@ -0,0 +1,98 @@ +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: number | undefined; + + return function executedFunction(...args: Parameters) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + + clearTimeout(timeout); + timeout = window.setTimeout(later, wait); + }; +} + +export function throttle any>( + func: T, + limit: number +): (...args: Parameters) => void { + let inThrottle: boolean; + + return function executedFunction(...args: Parameters) { + if (!inThrottle) { + func.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; +} + +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; +} + +export function isInViewport(element: Element, margin = 0): boolean { + const rect = element.getBoundingClientRect(); + return ( + rect.top >= -margin && + rect.left >= -margin && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) + margin && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + margin + ); +} + +export class PerformanceMonitor { + private static marks = new Map(); + private static measures: Array<{ name: string; duration: number }> = []; + + public static mark(name: string) { + this.marks.set(name, performance.now()); + } + + public static measure(name: string, startMark: string) { + const start = this.marks.get(startMark); + if (start) { + const duration = performance.now() - start; + this.measures.push({ name, duration }); + this.marks.delete(startMark); + return duration; + } + return 0; + } + + public static getReport() { + const report = { + measures: [...this.measures], + averages: {} as Record, + }; + + // Calculate averages for repeated measures + const grouped = new Map(); + for (const measure of this.measures) { + if (!grouped.has(measure.name)) { + grouped.set(measure.name, []); + } + grouped.get(measure.name)!.push(measure.duration); + } + + for (const [name, durations] of grouped) { + report.averages[name] = durations.reduce((a, b) => a + b, 0) / durations.length; + } + + return report; + } + + public static clear() { + this.marks.clear(); + this.measures = []; + } +} \ No newline at end of file diff --git a/ts_web/elements/dees-pdf-viewer/component.ts b/ts_web/elements/dees-pdf-viewer/component.ts new file mode 100644 index 0000000..9fa9253 --- /dev/null +++ b/ts_web/elements/dees-pdf-viewer/component.ts @@ -0,0 +1,386 @@ +import { DeesElement, property, html, customElement, domtools, type TemplateResult, type CSSResult, css, cssManager } from '@design.estate/dees-element'; +import { DeesInputBase } from '../dees-input-base.js'; +import { PdfManager } from '../dees-pdf-shared/PdfManager.js'; +import { viewerStyles } from './styles.js'; +import { demo as demoFunc } from './demo.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-pdf-viewer': DeesPdfViewer; + } +} + +@customElement('dees-pdf-viewer') +export class DeesPdfViewer extends DeesElement { + public static demo = demoFunc; + public static styles = viewerStyles; + + @property({ type: String }) + public pdfUrl: string = ''; + + @property({ type: Number }) + public initialPage: number = 1; + + @property({ type: String }) + public initialZoom: 'auto' | 'page-fit' | 'page-width' | number = 'auto'; + + @property({ type: Boolean }) + public showToolbar: boolean = true; + + @property({ type: Boolean }) + public showSidebar: boolean = false; + + @property({ type: Number }) + private currentPage: number = 1; + + @property({ type: Number }) + private totalPages: number = 1; + + @property({ type: Number }) + private currentZoom: number = 1; + + @property({ type: Boolean }) + private loading: boolean = false; + + private pdfDocument: any; + private pageRendering: boolean = false; + private pageNumPending: number | null = null; + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + + constructor() { + super(); + } + + public render(): TemplateResult { + return html` +
+ ${this.showToolbar ? html` +
+
+ +
+ + / + ${this.totalPages} +
+ +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+
+ ` : ''} + +
+ ${this.showSidebar ? html` + + ` : ''} + +
+ ${this.loading ? html` +
+
+
Loading PDF...
+
+ ` : html` +
+ +
+ `} +
+
+
+ `; + } + + public async connectedCallback() { + super.connectedCallback(); + await this.updateComplete; + if (this.pdfUrl) { + await this.loadPdf(); + } + } + + public async updated(changedProperties: Map) { + super.updated(changedProperties); + + if (changedProperties.has('pdfUrl') && this.pdfUrl) { + await this.loadPdf(); + } + } + + private async loadPdf() { + this.loading = true; + + try { + this.pdfDocument = await PdfManager.loadDocument(this.pdfUrl); + this.totalPages = this.pdfDocument.numPages; + this.currentPage = this.initialPage; + + await this.updateComplete; + this.canvas = this.shadowRoot?.querySelector('#pdf-canvas') as HTMLCanvasElement; + this.ctx = this.canvas?.getContext('2d') as CanvasRenderingContext2D; + + await this.renderPage(this.currentPage); + + if (this.showSidebar) { + this.renderThumbnails(); + } + } catch (error) { + console.error('Error loading PDF:', error); + } finally { + this.loading = false; + } + } + + private async renderPage(pageNum: number) { + if (!this.pdfDocument || !this.canvas || !this.ctx) return; + + this.pageRendering = true; + + try { + const page = await this.pdfDocument.getPage(pageNum); + + let viewport; + if (this.initialZoom === 'auto' || this.initialZoom === 'page-fit') { + const tempViewport = page.getViewport({ scale: 1 }); + const containerWidth = this.canvas.parentElement?.clientWidth || 800; + const containerHeight = this.canvas.parentElement?.clientHeight || 600; + const scaleX = containerWidth / tempViewport.width; + const scaleY = containerHeight / tempViewport.height; + this.currentZoom = Math.min(scaleX, scaleY); + viewport = page.getViewport({ scale: this.currentZoom }); + } else if (this.initialZoom === 'page-width') { + const tempViewport = page.getViewport({ scale: 1 }); + const containerWidth = this.canvas.parentElement?.clientWidth || 800; + this.currentZoom = containerWidth / tempViewport.width; + viewport = page.getViewport({ scale: this.currentZoom }); + } else { + this.currentZoom = typeof this.initialZoom === 'number' ? this.initialZoom : 1; + viewport = page.getViewport({ scale: this.currentZoom }); + } + + this.canvas.height = viewport.height; + this.canvas.width = viewport.width; + + const renderContext = { + canvasContext: this.ctx, + viewport: viewport, + }; + + await page.render(renderContext).promise; + + this.pageRendering = false; + + if (this.pageNumPending !== null) { + await this.renderPage(this.pageNumPending); + this.pageNumPending = null; + } + } catch (error) { + console.error('Error rendering page:', error); + this.pageRendering = false; + } + } + + private queueRenderPage(pageNum: number) { + if (this.pageRendering) { + this.pageNumPending = pageNum; + } else { + this.renderPage(pageNum); + } + } + + private async renderThumbnails() { + await this.updateComplete; + const thumbnails = this.shadowRoot?.querySelectorAll('.thumbnail-canvas') as NodeListOf; + const thumbnailWidth = 176; // Fixed width for thumbnails (200px container - 24px padding) + + for (const canvas of Array.from(thumbnails)) { + const pageNum = parseInt(canvas.dataset.page || '1'); + const page = await this.pdfDocument.getPage(pageNum); + + // Calculate scale to fit thumbnail width while maintaining aspect ratio + const initialViewport = page.getViewport({ scale: 1 }); + const scale = thumbnailWidth / initialViewport.width; + const viewport = page.getViewport({ scale }); + + // Set canvas dimensions to actual render size + canvas.width = viewport.width; + canvas.height = viewport.height; + + // Also set the display size via style to ensure proper display + canvas.style.width = `${viewport.width}px`; + canvas.style.height = `${viewport.height}px`; + + const context = canvas.getContext('2d'); + const renderContext = { + canvasContext: context, + viewport: viewport, + }; + + await page.render(renderContext).promise; + } + } + + private previousPage() { + if (this.currentPage > 1) { + this.currentPage--; + this.queueRenderPage(this.currentPage); + } + } + + private nextPage() { + if (this.currentPage < this.totalPages) { + this.currentPage++; + this.queueRenderPage(this.currentPage); + } + } + + private goToPage(pageNum: number) { + if (pageNum >= 1 && pageNum <= this.totalPages) { + this.currentPage = pageNum; + this.queueRenderPage(this.currentPage); + } + } + + private handlePageInput(e: Event) { + const input = e.target as HTMLInputElement; + const pageNum = parseInt(input.value); + this.goToPage(pageNum); + } + + private zoomIn() { + if (this.currentZoom < 3) { + this.currentZoom = Math.min(3, this.currentZoom * 1.2); + this.queueRenderPage(this.currentPage); + } + } + + private zoomOut() { + if (this.currentZoom > 0.5) { + this.currentZoom = Math.max(0.5, this.currentZoom / 1.2); + this.queueRenderPage(this.currentPage); + } + } + + private resetZoom() { + this.currentZoom = 1; + this.queueRenderPage(this.currentPage); + } + + private fitToPage() { + this.initialZoom = 'page-fit'; + this.queueRenderPage(this.currentPage); + } + + private fitToWidth() { + this.initialZoom = 'page-width'; + this.queueRenderPage(this.currentPage); + } + + private downloadPdf() { + const link = document.createElement('a'); + link.href = this.pdfUrl; + link.download = this.pdfUrl.split('/').pop() || 'document.pdf'; + link.click(); + } + + private printPdf() { + window.open(this.pdfUrl, '_blank')?.print(); + } +} \ No newline at end of file diff --git a/ts_web/elements/dees-pdf-viewer/demo.ts b/ts_web/elements/dees-pdf-viewer/demo.ts new file mode 100644 index 0000000..6fbeab7 --- /dev/null +++ b/ts_web/elements/dees-pdf-viewer/demo.ts @@ -0,0 +1,69 @@ +import { html } from '@design.estate/dees-element'; + +export const demo = () => html` + + +
+
+

Full Featured PDF Viewer with Toolbar

+ +
+ +
+

PDF Viewer with Sidebar Navigation

+ +
+ +
+

Compact Viewer without Controls

+ +
+
+`; \ No newline at end of file diff --git a/ts_web/elements/dees-pdf-viewer/index.ts b/ts_web/elements/dees-pdf-viewer/index.ts new file mode 100644 index 0000000..9455f54 --- /dev/null +++ b/ts_web/elements/dees-pdf-viewer/index.ts @@ -0,0 +1 @@ +export * from './component.js'; \ No newline at end of file diff --git a/ts_web/elements/dees-pdf-viewer/styles.ts b/ts_web/elements/dees-pdf-viewer/styles.ts new file mode 100644 index 0000000..d8afadc --- /dev/null +++ b/ts_web/elements/dees-pdf-viewer/styles.ts @@ -0,0 +1,262 @@ +import { css, cssManager } from '@design.estate/dees-element'; + +export const viewerStyles = [ + cssManager.defaultStyles, + css` + :host { + display: block; + width: 100%; + height: 600px; + position: relative; + font-family: 'Geist Sans', sans-serif; + } + + .pdf-viewer { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(215 20% 10%)')}; + } + + .toolbar { + height: 48px; + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 15%)')}; + border-bottom: 1px solid ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 22%)')}; + display: flex; + align-items: center; + padding: 0 16px; + gap: 16px; + flex-shrink: 0; + } + + .toolbar-group { + display: flex; + align-items: center; + gap: 4px; + } + + .toolbar-group--end { + margin-left: auto; + } + + .toolbar-button { + width: 32px; + height: 32px; + border-radius: 6px; + background: transparent; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s ease; + color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')}; + } + + .toolbar-button:hover:not(:disabled) { + background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 25% 22%)')}; + } + + .toolbar-button:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .toolbar-button dees-icon { + font-size: 16px; + } + + .page-info { + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px; + font-size: 14px; + color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')}; + } + + .page-input { + width: 48px; + height: 28px; + border-radius: 4px; + border: 1px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 28%)')}; + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')}; + color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')}; + text-align: center; + font-size: 14px; + font-family: inherit; + outline: none; + } + + .page-input:focus { + border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; + } + + .page-separator { + color: ${cssManager.bdTheme('hsl(215 16% 60%)', 'hsl(215 16% 50%)')}; + } + + .zoom-level { + font-size: 13px; + font-weight: 500; + min-width: 48px; + text-align: center; + } + + .viewer-container { + flex: 1; + display: flex; + overflow: hidden; + position: relative; + } + + .sidebar { + width: 200px; + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 15%)')}; + border-right: 1px solid ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 22%)')}; + display: flex; + flex-direction: column; + } + + .sidebar-header { + height: 40px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 22%)')}; + font-size: 13px; + font-weight: 600; + color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')}; + } + + .sidebar-close { + width: 24px; + height: 24px; + border-radius: 4px; + background: transparent; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')}; + transition: background 0.15s ease; + } + + .sidebar-close:hover { + background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 25% 22%)')}; + } + + .sidebar-close dees-icon { + font-size: 14px; + } + + .sidebar-content { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; + } + + .thumbnail { + position: relative; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + border: 2px solid transparent; + transition: border-color 0.15s ease; + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 18%)')}; + display: flex; + align-items: center; + justify-content: center; + min-height: 100px; + } + + .thumbnail:hover { + border-color: ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 35%)')}; + } + + .thumbnail.active { + border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; + } + + .thumbnail-canvas { + display: block; + max-width: 100%; + height: auto; + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + } + + .thumbnail-number { + position: absolute; + bottom: 4px; + right: 4px; + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.7)', 'rgba(0, 0, 0, 0.8)')}; + color: white; + font-size: 11px; + font-weight: 500; + padding: 2px 6px; + border-radius: 4px; + } + + .viewer-main { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; + padding: 20px; + } + + .loading-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')}; + } + + .loading-spinner { + width: 32px; + height: 32px; + border-radius: 50%; + border: 3px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 28%)')}; + border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + .loading-text { + font-size: 14px; + font-weight: 500; + } + + .canvas-container { + background: white; + box-shadow: 0 2px 12px ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(0, 0, 0, 0.3)')}; + border-radius: 4px; + overflow: hidden; + display: inline-block; + } + + #pdf-canvas { + display: block; + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + } + + .pdf-viewer.with-sidebar .viewer-main { + margin-left: 0; + } + `, +]; \ No newline at end of file diff --git a/ts_web/elements/dees-pdf.ts b/ts_web/elements/dees-pdf/component.ts similarity index 93% rename from ts_web/elements/dees-pdf.ts rename to ts_web/elements/dees-pdf/component.ts index c4b20a9..e555476 100644 --- a/ts_web/elements/dees-pdf.ts +++ b/ts_web/elements/dees-pdf/component.ts @@ -10,6 +10,11 @@ declare global { } } +/** + * @deprecated Use DeesPdfViewer or DeesPdfPreview instead + * - DeesPdfViewer: Full-featured PDF viewing with controls, navigation, zoom + * - DeesPdfPreview: Lightweight, performance-optimized preview for grids + */ @customElement('dees-pdf') export class DeesPdf extends DeesElement { // DEMO @@ -107,4 +112,4 @@ export class DeesPdf extends DeesElement { } ); } -} +} \ No newline at end of file diff --git a/ts_web/elements/dees-pdf/index.ts b/ts_web/elements/dees-pdf/index.ts new file mode 100644 index 0000000..9455f54 --- /dev/null +++ b/ts_web/elements/dees-pdf/index.ts @@ -0,0 +1 @@ +export * from './component.js'; \ No newline at end of file diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 5fa6ddc..be1a88e 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -48,7 +48,9 @@ export * from './dees-mobilenavigation.js'; export * from './dees-modal.js'; export * from './dees-input-multitoggle.js'; export * from './dees-panel.js'; -export * from './dees-pdf.js'; +export * from './dees-pdf/index.js'; // @deprecated - Use dees-pdf-viewer or dees-pdf-preview instead +export * from './dees-pdf-viewer/index.js'; +export * from './dees-pdf-preview/index.js'; export * from './dees-searchbar.js'; export * from './dees-shopping-productcard.js'; export * from './dees-simple-appdash.js';