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`
+
+ ` : ''}
+
+ ${this.error ? html`
+
+ ` : ''}
+
+ ${!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
+
+
+
+
+
+
+
+
+
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)}
+
+
+
+
+
+ `;
+};
\ 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`
+
+ `;
+ }
+
+ 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';