diff --git a/package.json b/package.json index 30965cd..95b4687 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "apexcharts": "^5.3.5", "highlight.js": "11.11.1", "ibantools": "^4.5.1", + "lit": "^3.3.1", "lucide": "^0.544.0", "monaco-editor": "0.52.2", "pdfjs-dist": "^4.10.38", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69e8646..8e2fe6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: ibantools: specifier: ^4.5.1 version: 4.5.1 + lit: + specifier: ^3.3.1 + version: 3.3.1 lucide: specifier: ^0.544.0 version: 0.544.0 @@ -853,15 +856,9 @@ packages: '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} - '@lit-labs/ssr-dom-shim@1.3.0': - resolution: {integrity: sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==} - '@lit-labs/ssr-dom-shim@1.4.0': resolution: {integrity: sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==} - '@lit/reactive-element@2.1.0': - resolution: {integrity: sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==} - '@lit/reactive-element@2.1.1': resolution: {integrity: sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==} @@ -3850,9 +3847,6 @@ packages: linkifyjs@4.3.1: resolution: {integrity: sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==} - lit-element@4.2.0: - resolution: {integrity: sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==} - lit-element@4.2.1: resolution: {integrity: sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==} @@ -3862,9 +3856,6 @@ packages: lit-html@3.3.1: resolution: {integrity: sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==} - lit@3.3.0: - resolution: {integrity: sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==} - lit@3.3.1: resolution: {integrity: sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==} @@ -6466,7 +6457,7 @@ snapshots: '@push.rocks/websetup': 3.0.19 '@push.rocks/webstore': 2.0.20 lenis: 1.3.4 - lit: 3.3.0 + lit: 3.3.1 sweet-scroll: 4.0.0 transitivePeerDependencies: - '@nuxt/kit' @@ -6866,14 +6857,8 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} - '@lit-labs/ssr-dom-shim@1.3.0': {} - '@lit-labs/ssr-dom-shim@1.4.0': {} - '@lit/reactive-element@2.1.0': - dependencies: - '@lit-labs/ssr-dom-shim': 1.3.0 - '@lit/reactive-element@2.1.1': dependencies: '@lit-labs/ssr-dom-shim': 1.4.0 @@ -6994,7 +6979,7 @@ snapshots: '@open-wc/scoped-elements@3.0.5': dependencies: '@open-wc/dedupe-mixin': 1.4.0 - lit: 3.3.0 + lit: 3.3.1 '@open-wc/semantic-dom-diff@0.20.1': dependencies: @@ -7008,7 +6993,7 @@ snapshots: '@open-wc/testing-helpers@3.0.1': dependencies: '@open-wc/scoped-elements': 3.0.5 - lit: 3.3.0 + lit: 3.3.1 lit-html: 3.3.0 '@open-wc/testing@4.0.0': @@ -10989,12 +10974,6 @@ snapshots: linkifyjs@4.3.1: {} - lit-element@4.2.0: - dependencies: - '@lit-labs/ssr-dom-shim': 1.3.0 - '@lit/reactive-element': 2.1.0 - lit-html: 3.3.0 - lit-element@4.2.1: dependencies: '@lit-labs/ssr-dom-shim': 1.4.0 @@ -11009,12 +10988,6 @@ snapshots: dependencies: '@types/trusted-types': 2.0.7 - lit@3.3.0: - dependencies: - '@lit/reactive-element': 2.1.0 - lit-element: 4.2.0 - lit-html: 3.3.0 - lit@3.3.1: dependencies: '@lit/reactive-element': 2.1.1 diff --git a/readme.plan.md b/readme.plan.md index 885fb13..e69de29 100644 Binary files a/readme.plan.md and b/readme.plan.md differ diff --git a/ts_web/elements/dees-pdf-preview/component.ts b/ts_web/elements/dees-pdf-preview/component.ts index e8a0616..3f60478 100644 --- a/ts_web/elements/dees-pdf-preview/component.ts +++ b/ts_web/elements/dees-pdf-preview/component.ts @@ -42,10 +42,16 @@ export class DeesPdfPreview extends DeesElement { @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 renderRequestId: number; + private resizeObserver?: ResizeObserver; + private previewContainer: HTMLElement | null = null; + private stackElement: HTMLElement | null = null; + private loadedPdfUrl: string | null = null; constructor() { super(); @@ -118,13 +124,21 @@ export class DeesPdfPreview extends DeesElement { } public async connectedCallback() { - super.connectedCallback(); + await super.connectedCallback(); this.setupIntersectionObserver(); + await this.updateComplete; + this.cacheElements(); + this.setupResizeObserver(); } public async disconnectedCallback() { - super.disconnectedCallback(); + await super.disconnectedCallback(); this.cleanup(); + if (this.observer) { + this.observer.disconnect(); + } + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; } private setupIntersectionObserver() { @@ -161,9 +175,11 @@ export class DeesPdfPreview extends DeesElement { try { this.pdfDocument = await PdfManager.loadDocument(this.pdfUrl); this.pageCount = this.pdfDocument.numPages; + this.loadedPdfUrl = this.pdfUrl; await this.updateComplete; - await this.renderPages(); + this.cacheElements(); + await this.scheduleRenderPages(); this.rendered = true; @@ -177,17 +193,46 @@ export class DeesPdfPreview extends DeesElement { } } - private async renderPages() { + 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(); - // 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; + + 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++) { @@ -197,9 +242,15 @@ export class DeesPdfPreview extends DeesElement { const pageNum = parseInt(canvas.dataset.page || '1'); const page = await this.pdfDocument.getPage(pageNum); - // Calculate scale to fit within available width + // Calculate scale to fit within available area while keeping aspect ratio const initialViewport = page.getViewport({ scale: 1 }); - const scale = Math.min(availableWidth / initialViewport.width, 0.5); // Cap at 0.5 for quality + 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 @@ -231,12 +282,6 @@ export class DeesPdfPreview extends DeesElement { } 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); @@ -245,18 +290,22 @@ export class DeesPdfPreview extends DeesElement { } private cleanup() { - if (this.observer) { - this.observer.disconnect(); - } - this.clearCanvases(); - if (this.pdfUrl && this.pdfDocument) { - PdfManager.releaseDocument(this.pdfUrl); + 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() { @@ -277,6 +326,10 @@ export class DeesPdfPreview extends DeesElement { 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; @@ -288,6 +341,10 @@ export class DeesPdfPreview extends DeesElement { } } } + + if ((changedProperties.has('maxPages') || changedProperties.has('stackOffset')) && this.rendered) { + await this.scheduleRenderPages(); + } } /** @@ -351,4 +408,40 @@ export class DeesPdfPreview extends DeesElement { return items; } -} \ No newline at end of file + + 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 }; + } +} diff --git a/ts_web/elements/dees-pdf-shared/PdfManager.ts b/ts_web/elements/dees-pdf-shared/PdfManager.ts index 02ce4a4..4169aa4 100644 --- a/ts_web/elements/dees-pdf-shared/PdfManager.ts +++ b/ts_web/elements/dees-pdf-shared/PdfManager.ts @@ -1,15 +1,6 @@ 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; @@ -26,83 +17,20 @@ export class PdfManager { 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 + // IMPORTANT: Disabled caching to ensure component isolation + // Each viewer instance gets its own document to prevent state sharing + // This fixes issues where multiple viewers interfere with each other 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; - } - } + public static releaseDocument(_url: string) { + // No-op since we're not caching documents anymore + // Each viewer manages its own document lifecycle } - 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 + // Cache methods removed to ensure component isolation + // Each viewer now manages its own document lifecycle +} diff --git a/ts_web/elements/dees-pdf-viewer/component.ts b/ts_web/elements/dees-pdf-viewer/component.ts index c6e3e24..57f91ac 100644 --- a/ts_web/elements/dees-pdf-viewer/component.ts +++ b/ts_web/elements/dees-pdf-viewer/component.ts @@ -1,4 +1,6 @@ import { DeesElement, property, html, customElement, domtools, type TemplateResult, type CSSResult, css, cssManager } from '@design.estate/dees-element'; +import { keyed } from 'lit/directives/keyed.js'; +import { repeat } from 'lit/directives/repeat.js'; import { DeesInputBase } from '../dees-input-base.js'; import { PdfManager } from '../dees-pdf-shared/PdfManager.js'; import { viewerStyles } from './styles.js'; @@ -12,6 +14,8 @@ declare global { } } +type RenderState = 'idle' | 'loading' | 'rendering-main' | 'rendering-thumbs' | 'rendered' | 'error' | 'disposed'; + @customElement('dees-pdf-viewer') export class DeesPdfViewer extends DeesElement { public static demo = demoFunc; @@ -44,11 +48,31 @@ export class DeesPdfViewer extends DeesElement { @property({ type: Boolean }) private loading: boolean = false; + @property({ type: String }) + private documentId: string = ''; + + @property({ type: Array }) + private thumbnailData: Array<{page: number, rendered: boolean}> = []; + private pdfDocument: any; + private renderState: RenderState = 'idle'; + private renderAbortController: AbortController | null = null; private pageRendering: boolean = false; private pageNumPending: number | null = null; - private canvas: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; + private currentRenderTask: any = null; + private currentRenderPromise: Promise | null = null; + private thumbnailRenderTasks: any[] = []; + private canvas: HTMLCanvasElement | undefined; + private ctx: CanvasRenderingContext2D | undefined; + private viewerMain: HTMLElement | null = null; + private resizeObserver?: ResizeObserver; + private viewportDimensions = { width: 0, height: 0 }; + private viewportMode: 'auto' | 'page-fit' | 'page-width' | 'custom' = 'auto'; + private loadedPdfUrl: string | null = null; + private readonly MANUAL_MIN_ZOOM = 0.5; + private readonly MANUAL_MAX_ZOOM = 3; + private readonly ABSOLUTE_MIN_ZOOM = 0.1; + private readonly ABSOLUTE_MAX_ZOOM = 4; constructor() { super(); @@ -92,7 +116,7 @@ export class DeesPdfViewer extends DeesElement { @@ -105,7 +129,7 @@ export class DeesPdfViewer extends DeesElement { @@ -160,14 +184,21 @@ export class DeesPdfViewer extends DeesElement { @@ -191,90 +222,193 @@ export class DeesPdfViewer extends DeesElement { } public async connectedCallback() { - super.connectedCallback(); + await super.connectedCallback(); await this.updateComplete; + this.ensureViewerRefs(); + + // Generate a unique document ID for this connection if (this.pdfUrl) { + this.documentId = `${this.pdfUrl}-${Date.now()}-${Math.random()}`; await this.loadPdf(); } } + public async disconnectedCallback() { + await super.disconnectedCallback(); + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + + // Mark as disposed and clean up + this.renderState = 'disposed'; + await this.cleanupDocument(); + + // Clear all references + this.canvas = undefined; + this.ctx = undefined; + } + 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); + } + // Generate new document ID for new URL + this.documentId = `${this.pdfUrl}-${Date.now()}-${Math.random()}`; await this.loadPdf(); } + + // Only re-render thumbnails when sidebar becomes visible and document is loaded + if (changedProperties.has('showSidebar') && this.showSidebar && this.pdfDocument && this.renderState === 'rendered') { + // Use requestAnimationFrame to ensure DOM is ready + await new Promise(resolve => requestAnimationFrame(resolve)); + await this.renderThumbnails(); + } } private async loadPdf() { this.loading = true; + this.renderState = 'loading'; try { + await this.cleanupDocument(); + + // Create new abort controller for this load operation + this.renderAbortController = new AbortController(); + const signal = this.renderAbortController.signal; + this.pdfDocument = await PdfManager.loadDocument(this.pdfUrl); + if (signal.aborted) return; + this.totalPages = this.pdfDocument.numPages; this.currentPage = this.initialPage; + this.resolveInitialViewportMode(); + // Initialize thumbnail data array + this.thumbnailData = Array.from({length: this.totalPages}, (_, i) => ({ + page: i + 1, + rendered: false + })); + + // Set loading to false to render the canvas + this.loading = false; await this.updateComplete; - this.canvas = this.shadowRoot?.querySelector('#pdf-canvas') as HTMLCanvasElement; - this.ctx = this.canvas?.getContext('2d') as CanvasRenderingContext2D; + this.ensureViewerRefs(); + // Wait for next frame to ensure DOM is ready + await new Promise(resolve => requestAnimationFrame(resolve)); + if (signal.aborted) return; + + // Always re-acquire canvas references + this.canvas = this.shadowRoot?.querySelector('#pdf-canvas') as HTMLCanvasElement; + + if (!this.canvas) { + console.error('Canvas element not found in DOM'); + this.renderState = 'error'; + return; + } + + this.ctx = this.canvas.getContext('2d'); + + if (!this.ctx) { + console.error('Failed to acquire 2D rendering context'); + this.renderState = 'error'; + return; + } + + this.renderState = 'rendering-main'; await this.renderPage(this.currentPage); + if (signal.aborted) return; if (this.showSidebar) { - this.renderThumbnails(); + // Ensure sidebar is in DOM after loading = false + await this.updateComplete; + // Wait for next frame to ensure DOM is fully ready + await new Promise(resolve => requestAnimationFrame(resolve)); + if (signal.aborted) return; + + await this.renderThumbnails(); + if (signal.aborted) return; } + + this.renderState = 'rendered'; + this.loadedPdfUrl = this.pdfUrl; } catch (error) { console.error('Error loading PDF:', error); - } finally { this.loading = false; + this.renderState = 'error'; } } private async renderPage(pageNum: number) { if (!this.pdfDocument || !this.canvas || !this.ctx) return; + // Wait for any existing render to complete + if (this.currentRenderPromise) { + try { + await this.currentRenderPromise; + } catch (error) { + // Ignore errors from previous renders + } + } + + // Create a new promise for this render + this.currentRenderPromise = this._doRenderPage(pageNum); + + try { + await this.currentRenderPromise; + } finally { + this.currentRenderPromise = null; + } + } + + private async _doRenderPage(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 }); + if (!this.ctx) { + console.error('Unable to acquire canvas rendering context'); + this.pageRendering = false; + return; } + const viewport = this.computeViewport(page); this.canvas.height = viewport.height; this.canvas.width = viewport.width; + this.canvas.style.width = `${viewport.width}px`; + this.canvas.style.height = `${viewport.height}px`; const renderContext = { canvasContext: this.ctx, viewport: viewport, }; - await page.render(renderContext).promise; + // Store the render task + this.currentRenderTask = page.render(renderContext); + await this.currentRenderTask.promise; + this.currentRenderTask = null; this.pageRendering = false; + + // Clean up the page object + page.cleanup?.(); if (this.pageNumPending !== null) { - await this.renderPage(this.pageNumPending); + const nextPage = this.pageNumPending; this.pageNumPending = null; + await this.renderPage(nextPage); } } catch (error) { - console.error('Error rendering page:', error); + // Ignore cancellation errors + if (error?.name !== 'RenderingCancelledException') { + console.error('Error rendering page:', error); + } + this.currentRenderTask = null; this.pageRendering = false; } } @@ -288,34 +422,97 @@ export class DeesPdfViewer extends DeesElement { } 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) + // Check if document is loaded + if (!this.pdfDocument) { + return; + } - for (const canvas of Array.from(thumbnails)) { - const pageNum = parseInt(canvas.dataset.page || '1'); - const page = await this.pdfDocument.getPage(pageNum); + // Check if already rendered + if (this.thumbnailData.length > 0 && this.thumbnailData.every(t => t.rendered)) { + return; + } - // 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 }); + // Check abort signal + if (this.renderAbortController?.signal.aborted) { + return; + } - // Set canvas dimensions to actual render size - canvas.width = viewport.width; - canvas.height = viewport.height; + const signal = this.renderAbortController?.signal; + this.renderState = 'rendering-thumbs'; - // Also set the display size via style to ensure proper display - canvas.style.width = `${viewport.width}px`; - canvas.style.height = `${viewport.height}px`; + // Cancel any existing thumbnail render tasks + for (const task of this.thumbnailRenderTasks) { + try { + task.cancel(); + } catch (error) { + // Ignore cancellation errors + } + } + this.thumbnailRenderTasks = []; - const context = canvas.getContext('2d'); - const renderContext = { - canvasContext: context, - viewport: viewport, - }; + try { + await this.updateComplete; + const thumbnails = this.shadowRoot?.querySelectorAll('.thumbnail-canvas') as NodeListOf; + const thumbnailWidth = 176; // Fixed width for thumbnails (200px container - 24px padding) - await page.render(renderContext).promise; + // Clear all canvases first to prevent conflicts + for (const canvas of Array.from(thumbnails)) { + const context = canvas.getContext('2d'); + if (context) { + context.clearRect(0, 0, canvas.width, canvas.height); + } + } + + for (const canvas of Array.from(thumbnails)) { + if (signal?.aborted) return; + + 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'); + if (!context) { + page.cleanup?.(); + continue; + } + const renderContext = { + canvasContext: context, + viewport: viewport, + }; + + const renderTask = page.render(renderContext); + this.thumbnailRenderTasks.push(renderTask); + await renderTask.promise; + page.cleanup?.(); + + // Mark this thumbnail as rendered + const thumbData = this.thumbnailData.find(t => t.page === pageNum); + if (thumbData) { + thumbData.rendered = true; + } + } + + // Trigger update to reflect rendered state + this.requestUpdate('thumbnailData'); + } catch (error: any) { + // Only log non-cancellation errors + if (error?.name !== 'RenderingCancelledException') { + console.error('Error rendering thumbnails:', error); + } + } finally { + this.thumbnailRenderTasks = []; } } @@ -333,13 +530,29 @@ export class DeesPdfViewer extends DeesElement { } } - private goToPage(pageNum: number) { + private async goToPage(pageNum: number) { if (pageNum >= 1 && pageNum <= this.totalPages) { this.currentPage = pageNum; - this.queueRenderPage(this.currentPage); + + // Ensure canvas references are available + if (!this.canvas || !this.ctx) { + await this.updateComplete; + this.canvas = this.shadowRoot?.querySelector('#pdf-canvas') as HTMLCanvasElement; + this.ctx = this.canvas?.getContext('2d') || null; + } + + if (this.canvas && this.ctx) { + this.queueRenderPage(this.currentPage); + } } } + private handleThumbnailClick(e: Event) { + const target = e.currentTarget as HTMLElement; + const pageNum = parseInt(target.dataset.page || '1'); + this.goToPage(pageNum); + } + private handlePageInput(e: Event) { const input = e.target as HTMLInputElement; const pageNum = parseInt(input.value); @@ -347,31 +560,36 @@ export class DeesPdfViewer extends DeesElement { } private zoomIn() { - if (this.currentZoom < 3) { - this.currentZoom = Math.min(3, this.currentZoom * 1.2); + const nextZoom = Math.min(this.MANUAL_MAX_ZOOM, this.currentZoom * 1.2); + this.viewportMode = 'custom'; + if (nextZoom !== this.currentZoom) { + this.currentZoom = nextZoom; this.queueRenderPage(this.currentPage); } } private zoomOut() { - if (this.currentZoom > 0.5) { - this.currentZoom = Math.max(0.5, this.currentZoom / 1.2); + const nextZoom = Math.max(this.MANUAL_MIN_ZOOM, this.currentZoom / 1.2); + this.viewportMode = 'custom'; + if (nextZoom !== this.currentZoom) { + this.currentZoom = nextZoom; this.queueRenderPage(this.currentPage); } } private resetZoom() { + this.viewportMode = 'custom'; this.currentZoom = 1; this.queueRenderPage(this.currentPage); } private fitToPage() { - this.initialZoom = 'page-fit'; + this.viewportMode = 'page-fit'; this.queueRenderPage(this.currentPage); } private fitToWidth() { - this.initialZoom = 'page-width'; + this.viewportMode = 'page-width'; this.queueRenderPage(this.currentPage); } @@ -422,4 +640,168 @@ export class DeesPdfViewer extends DeesElement { } ]; } -} \ No newline at end of file + + private get canZoomIn(): boolean { + return this.viewportMode !== 'custom' || this.currentZoom < this.MANUAL_MAX_ZOOM; + } + + private get canZoomOut(): boolean { + return this.viewportMode !== 'custom' || this.currentZoom > this.MANUAL_MIN_ZOOM; + } + + private ensureViewerRefs() { + if (!this.viewerMain) { + this.viewerMain = this.shadowRoot?.querySelector('.viewer-main') as HTMLElement; + } + if (this.viewerMain && !this.resizeObserver) { + this.resizeObserver = new ResizeObserver(() => { + this.measureViewportDimensions(); + if (this.pdfDocument) { + this.queueRenderPage(this.currentPage); + } + }); + this.resizeObserver.observe(this.viewerMain); + this.measureViewportDimensions(); + } + } + + private measureViewportDimensions() { + if (!this.viewerMain) { + this.viewportDimensions = { width: 0, height: 0 }; + return; + } + + const styles = getComputedStyle(this.viewerMain); + const paddingX = parseFloat(styles.paddingLeft || '0') + parseFloat(styles.paddingRight || '0'); + const paddingY = parseFloat(styles.paddingTop || '0') + parseFloat(styles.paddingBottom || '0'); + const width = Math.max(this.viewerMain.clientWidth - paddingX, 0); + const height = Math.max(this.viewerMain.clientHeight - paddingY, 0); + this.viewportDimensions = { width, height }; + } + + private resolveInitialViewportMode() { + if (typeof this.initialZoom === 'number') { + this.viewportMode = 'custom'; + this.currentZoom = this.normalizeZoom(this.initialZoom, true); + } else if (this.initialZoom === 'page-width') { + this.viewportMode = 'page-width'; + } else if (this.initialZoom === 'page-fit' || this.initialZoom === 'auto') { + this.viewportMode = 'page-fit'; + } else { + this.viewportMode = 'auto'; + } + + if (this.viewportMode !== 'custom') { + this.currentZoom = 1; + } + } + + private computeViewport(page: any) { + this.measureViewportDimensions(); + const baseViewport = page.getViewport({ scale: 1 }); + let scale: number; + + switch (this.viewportMode) { + case 'page-width': { + const availableWidth = this.viewportDimensions.width || baseViewport.width; + scale = availableWidth / baseViewport.width; + break; + } + case 'page-fit': + case 'auto': { + const availableWidth = this.viewportDimensions.width || baseViewport.width; + const availableHeight = this.viewportDimensions.height || baseViewport.height; + const widthScale = availableWidth / baseViewport.width; + const heightScale = availableHeight / baseViewport.height; + scale = Math.min(widthScale, heightScale); + break; + } + case 'custom': + default: { + scale = this.normalizeZoom(this.currentZoom || 1, false); + break; + } + } + + if (!Number.isFinite(scale) || scale <= 0) { + scale = 1; + } + + const clampedScale = this.viewportMode === 'custom' + ? this.normalizeZoom(scale, true) + : this.normalizeZoom(scale, false); + + if (this.viewportMode !== 'custom') { + this.currentZoom = clampedScale; + } + + return page.getViewport({ scale: clampedScale }); + } + + private normalizeZoom(value: number, clampToManualRange: boolean) { + const min = clampToManualRange ? this.MANUAL_MIN_ZOOM : this.ABSOLUTE_MIN_ZOOM; + const max = clampToManualRange ? this.MANUAL_MAX_ZOOM : this.ABSOLUTE_MAX_ZOOM; + return Math.min(Math.max(value, min), max); + } + + private async cleanupDocument() { + // Abort any ongoing render operations + if (this.renderAbortController) { + this.renderAbortController.abort(); + this.renderAbortController = null; + } + + // Wait for any existing render to complete + if (this.currentRenderPromise) { + try { + await this.currentRenderPromise; + } catch (error) { + // Ignore errors + } + this.currentRenderPromise = null; + } + + // Clear the render task reference + this.currentRenderTask = null; + + // Cancel any thumbnail render tasks + for (const task of (this.thumbnailRenderTasks || [])) { + try { + task.cancel(); + } catch (error) { + // Ignore cancellation errors + } + } + this.thumbnailRenderTasks = []; + + // Reset all state flags + this.renderState = 'idle'; + this.pageRendering = false; + this.pageNumPending = null; + this.thumbnailData = []; + this.documentId = ''; + + // Clear canvas content + if (this.canvas && this.ctx) { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + + // Destroy the document to free memory + if (this.pdfDocument) { + try { + this.pdfDocument.destroy(); + } catch (error) { + console.error('Error destroying PDF document:', error); + } + } + + // Clear the loaded URL reference + this.loadedPdfUrl = null; + + // Finally null the document reference + this.pdfDocument = null; + + // Request update to reflect state changes + this.requestUpdate(); + } +}