From d9703d3ce3430044fe7c0bb29197a67ade0998de Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 20 Sep 2025 21:28:43 +0000 Subject: [PATCH] feat: Update PDF components to improve rendering performance and manage document lifecycle without caching --- package.json | 1 + pnpm-lock.yaml | 39 +- readme.plan.md | Bin 12176 -> 0 bytes ts_web/elements/dees-pdf-preview/component.ts | 139 ++++- ts_web/elements/dees-pdf-shared/PdfManager.ts | 90 +-- ts_web/elements/dees-pdf-viewer/component.ts | 520 +++++++++++++++--- 6 files changed, 583 insertions(+), 206 deletions(-) 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 885fb13ad2456c58da14239f8ada3a4006d78253..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 GIT binary patch literal 0 HcmV?d00001 literal 12176 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 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(); + } +}