diff --git a/ts_web/elements/dees-pdf-viewer/component.ts b/ts_web/elements/dees-pdf-viewer/component.ts index 3635c4f..4f71d1a 100644 --- a/ts_web/elements/dees-pdf-viewer/component.ts +++ b/ts_web/elements/dees-pdf-viewer/component.ts @@ -52,6 +52,9 @@ export class DeesPdfViewer extends DeesElement { @property({ type: Array }) private thumbnailData: Array<{page: number, rendered: boolean}> = []; + @property({ type: Array }) + private pageData: Array<{page: number, rendered: boolean, rendering: boolean}> = []; + private pdfDocument: any; private renderState: RenderState = 'idle'; private renderAbortController: AbortController | null = null; @@ -60,16 +63,21 @@ export class DeesPdfViewer extends DeesElement { private currentRenderTask: any = null; private currentRenderPromise: Promise | null = null; private thumbnailRenderTasks: any[] = []; + private pageRenderTasks: Map = new Map(); private canvas: HTMLCanvasElement | undefined; private ctx: CanvasRenderingContext2D | undefined; private viewerMain: HTMLElement | null = null; private resizeObserver?: ResizeObserver; + private intersectionObserver?: IntersectionObserver; + private scrollThrottleTimeout?: number; private viewportDimensions = { width: 0, height: 0 }; private viewportMode: 'auto' | 'page-fit' | 'page-width' | 'custom' = 'auto'; 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; + private readonly PAGE_GAP = 20; + private readonly RENDER_BUFFER = 3; constructor() { super(); @@ -201,15 +209,25 @@ export class DeesPdfViewer extends DeesElement { ` : ''} -
+
${this.loading ? html`
Loading PDF...
` : html` -
- +
+ ${repeat( + this.pageData, + (item) => item.page, + (item) => html` +
+
+ +
+
+ ` + )}
`}
@@ -234,6 +252,14 @@ export class DeesPdfViewer extends DeesElement { await super.disconnectedCallback(); this.resizeObserver?.disconnect(); this.resizeObserver = undefined; + this.intersectionObserver?.disconnect(); + this.intersectionObserver = undefined; + + // Clear scroll timeout + if (this.scrollThrottleTimeout) { + clearTimeout(this.scrollThrottleTimeout); + this.scrollThrottleTimeout = undefined; + } // Mark as disposed and clean up this.renderState = 'disposed'; @@ -283,42 +309,39 @@ export class DeesPdfViewer extends DeesElement { this.currentPage = this.initialPage; this.resolveInitialViewportMode(); - // Initialize thumbnail data array + // Initialize thumbnail and page data arrays this.thumbnailData = Array.from({length: this.totalPages}, (_, i) => ({ page: i + 1, rendered: false })); - // Set loading to false to render the canvas + this.pageData = Array.from({length: this.totalPages}, (_, i) => ({ + page: i + 1, + rendered: false, + rendering: false + })); + + // Set loading to false to render the pages this.loading = false; await this.updateComplete; this.ensureViewerRefs(); + this.setupIntersectionObserver(); // 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); + + // Render initial visible pages + await this.renderVisiblePages(); if (signal.aborted) return; + // Scroll to initial page + if (this.initialPage > 1) { + await this.scrollToPage(this.initialPage, false); + } + if (this.showSidebar) { // Ensure sidebar is in DOM after loading = false await this.updateComplete; @@ -338,82 +361,169 @@ export class DeesPdfViewer extends DeesElement { } } - 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 - } + private setupIntersectionObserver() { + if (this.intersectionObserver) { + this.intersectionObserver.disconnect(); } - // Create a new promise for this render - this.currentRenderPromise = this._doRenderPage(pageNum); - - try { - await this.currentRenderPromise; - } finally { - this.currentRenderPromise = null; + this.intersectionObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const pageWrapper = entry.target as HTMLElement; + const pageNum = parseInt(pageWrapper.dataset.page || '1'); + + if (entry.isIntersecting) { + this.renderPageIfNeeded(pageNum); + } + } + }, + { + root: this.viewerMain, + rootMargin: `${this.RENDER_BUFFER * 100}px 0px`, + threshold: 0.01 + } + ); + + // Observe all page wrappers + const pageWrappers = this.shadowRoot?.querySelectorAll('.page-wrapper'); + if (pageWrappers) { + pageWrappers.forEach(wrapper => { + this.intersectionObserver?.observe(wrapper); + }); } } - private async _doRenderPage(pageNum: number) { - if (!this.pdfDocument || !this.canvas || !this.ctx) return; + private async renderVisiblePages() { + if (!this.viewerMain) return; - this.pageRendering = true; + // Find visible pages based on scroll position + const scrollTop = this.viewerMain.scrollTop; + const clientHeight = this.viewerMain.clientHeight; + + for (const pageInfo of this.pageData) { + const pageWrapper = this.shadowRoot?.querySelector(`.page-wrapper[data-page="${pageInfo.page}"]`) as HTMLElement; + if (!pageWrapper) continue; + + const rect = pageWrapper.getBoundingClientRect(); + const viewerRect = this.viewerMain.getBoundingClientRect(); + const relativeTop = rect.top - viewerRect.top; + const relativeBottom = relativeTop + rect.height; + + // Check if page is visible or within buffer zone + const buffer = this.RENDER_BUFFER * clientHeight; + if (relativeBottom >= -buffer && relativeTop <= clientHeight + buffer) { + await this.renderPageIfNeeded(pageInfo.page); + } + } + } + + private async renderPageIfNeeded(pageNum: number) { + const pageInfo = this.pageData.find(p => p.page === pageNum); + if (!pageInfo || pageInfo.rendered || pageInfo.rendering) return; + + pageInfo.rendering = true; try { - const page = await this.pdfDocument.getPage(pageNum); - if (!this.ctx) { - console.error('Unable to acquire canvas rendering context'); - this.pageRendering = false; + const canvas = this.shadowRoot?.querySelector(`.page-canvas[data-page="${pageNum}"]`) as HTMLCanvasElement; + if (!canvas) { + pageInfo.rendering = false; return; } + + const page = await this.pdfDocument.getPage(pageNum); 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`; + // Set canvas dimensions + canvas.height = viewport.height; + canvas.width = viewport.width; + canvas.style.width = `${viewport.width}px`; + canvas.style.height = `${viewport.height}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + page.cleanup?.(); + pageInfo.rendering = false; + return; + } const renderContext = { - canvasContext: this.ctx, + canvasContext: ctx, viewport: viewport, }; - // Store the render task - this.currentRenderTask = page.render(renderContext); - await this.currentRenderTask.promise; + const renderTask = page.render(renderContext); + this.pageRenderTasks.set(pageNum, renderTask); + + await renderTask.promise; - this.currentRenderTask = null; - this.pageRendering = false; - - // Clean up the page object page.cleanup?.(); + pageInfo.rendered = true; + pageInfo.rendering = false; + this.pageRenderTasks.delete(pageNum); - if (this.pageNumPending !== null) { - const nextPage = this.pageNumPending; - this.pageNumPending = null; - await this.renderPage(nextPage); - } - } catch (error) { - // Ignore cancellation errors + // Update page data to reflect rendered state + this.requestUpdate('pageData'); + } catch (error: any) { if (error?.name !== 'RenderingCancelledException') { - console.error('Error rendering page:', error); + console.error(`Error rendering page ${pageNum}:`, error); } - this.currentRenderTask = null; - this.pageRendering = false; + pageInfo.rendering = false; + this.pageRenderTasks.delete(pageNum); } } - private queueRenderPage(pageNum: number) { - if (this.pageRendering) { - this.pageNumPending = pageNum; - } else { - this.renderPage(pageNum); + private handleScroll = () => { + // Throttle scroll events + if (this.scrollThrottleTimeout) { + clearTimeout(this.scrollThrottleTimeout); + } + + this.scrollThrottleTimeout = window.setTimeout(() => { + this.updateCurrentPage(); + this.renderVisiblePages(); + }, 50); + } + + private updateCurrentPage() { + if (!this.viewerMain) return; + + const scrollTop = this.viewerMain.scrollTop; + const clientHeight = this.viewerMain.clientHeight; + const centerY = scrollTop + clientHeight / 2; + + // Find which page is at the center of the viewport + for (let i = 0; i < this.pageData.length; i++) { + const pageWrapper = this.shadowRoot?.querySelector(`.page-wrapper[data-page="${i + 1}"]`) as HTMLElement; + if (!pageWrapper) continue; + + const rect = pageWrapper.getBoundingClientRect(); + const viewerRect = this.viewerMain.getBoundingClientRect(); + const relativeTop = rect.top - viewerRect.top + scrollTop; + const relativeBottom = relativeTop + rect.height; + + if (centerY >= relativeTop && centerY <= relativeBottom) { + if (this.currentPage !== i + 1) { + this.currentPage = i + 1; + } + break; + } + } + } + + private async scrollToPage(pageNum: number, smooth: boolean = true) { + await this.updateComplete; + const pageWrapper = this.shadowRoot?.querySelector(`.page-wrapper[data-page="${pageNum}"]`) as HTMLElement; + if (pageWrapper && this.viewerMain) { + pageWrapper.scrollIntoView({ + behavior: smooth ? 'smooth' : 'auto', + block: 'start' + }); + + // Update current page + this.currentPage = pageNum; + + // Ensure the page is rendered + await this.renderPageIfNeeded(pageNum); } } @@ -514,45 +624,32 @@ export class DeesPdfViewer extends DeesElement { private previousPage() { if (this.currentPage > 1) { - this.currentPage--; - this.queueRenderPage(this.currentPage); + this.scrollToPage(this.currentPage - 1); } } private nextPage() { if (this.currentPage < this.totalPages) { - this.currentPage++; - this.queueRenderPage(this.currentPage); + this.scrollToPage(this.currentPage + 1); } } private async goToPage(pageNum: number) { if (pageNum >= 1 && pageNum <= this.totalPages) { - this.currentPage = pageNum; - - // 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); - } + await this.scrollToPage(pageNum); } } private handleThumbnailClick(e: Event) { const target = e.currentTarget as HTMLElement; const pageNum = parseInt(target.dataset.page || '1'); - this.goToPage(pageNum); + this.scrollToPage(pageNum); } private handlePageInput(e: Event) { const input = e.target as HTMLInputElement; const pageNum = parseInt(input.value); - this.goToPage(pageNum); + this.scrollToPage(pageNum); } private zoomIn() { @@ -560,7 +657,7 @@ export class DeesPdfViewer extends DeesElement { this.viewportMode = 'custom'; if (nextZoom !== this.currentZoom) { this.currentZoom = nextZoom; - this.queueRenderPage(this.currentPage); + this.reRenderAllPages(); } } @@ -569,24 +666,50 @@ export class DeesPdfViewer extends DeesElement { this.viewportMode = 'custom'; if (nextZoom !== this.currentZoom) { this.currentZoom = nextZoom; - this.queueRenderPage(this.currentPage); + this.reRenderAllPages(); } } private resetZoom() { this.viewportMode = 'custom'; this.currentZoom = 1; - this.queueRenderPage(this.currentPage); + this.reRenderAllPages(); } private fitToPage() { this.viewportMode = 'page-fit'; - this.queueRenderPage(this.currentPage); + this.reRenderAllPages(); } private fitToWidth() { this.viewportMode = 'page-width'; - this.queueRenderPage(this.currentPage); + this.reRenderAllPages(); + } + + private reRenderAllPages() { + // Clear all rendered pages to force re-render with new zoom + this.pageData.forEach(page => { + page.rendered = false; + page.rendering = false; + }); + + // Cancel any ongoing render tasks + this.pageRenderTasks.forEach(task => { + try { + task.cancel(); + } catch (error) { + // Ignore cancellation errors + } + }); + this.pageRenderTasks.clear(); + + // Request update to re-render pages + this.requestUpdate(); + + // Render visible pages after update + this.updateComplete.then(() => { + this.renderVisiblePages(); + }); } private downloadPdf() { @@ -653,7 +776,8 @@ export class DeesPdfViewer extends DeesElement { this.resizeObserver = new ResizeObserver(() => { this.measureViewportDimensions(); if (this.pdfDocument) { - this.queueRenderPage(this.currentPage); + // Re-render all pages when viewport size changes + this.reRenderAllPages(); } }); this.resizeObserver.observe(this.viewerMain); @@ -760,6 +884,16 @@ export class DeesPdfViewer extends DeesElement { // Clear the render task reference this.currentRenderTask = null; + // Cancel any page render tasks + this.pageRenderTasks.forEach(task => { + try { + task.cancel(); + } catch (error) { + // Ignore cancellation errors + } + }); + this.pageRenderTasks.clear(); + // Cancel any thumbnail render tasks for (const task of (this.thumbnailRenderTasks || [])) { try { @@ -775,6 +909,7 @@ export class DeesPdfViewer extends DeesElement { this.pageRendering = false; this.pageNumPending = null; this.thumbnailData = []; + this.pageData = []; this.documentId = ''; // Clear canvas content diff --git a/ts_web/elements/dees-pdf-viewer/styles.ts b/ts_web/elements/dees-pdf-viewer/styles.ts index d8afadc..70e2ac7 100644 --- a/ts_web/elements/dees-pdf-viewer/styles.ts +++ b/ts_web/elements/dees-pdf-viewer/styles.ts @@ -206,17 +206,18 @@ export const viewerStyles = [ .viewer-main { flex: 1; - display: flex; - align-items: center; - justify-content: center; - overflow: auto; + overflow-y: auto; + overflow-x: hidden; padding: 20px; + scroll-behavior: smooth; } .loading-container { display: flex; flex-direction: column; align-items: center; + justify-content: center; + height: 100%; gap: 16px; color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')}; } @@ -241,6 +242,19 @@ export const viewerStyles = [ font-weight: 500; } + .pages-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + } + + .page-wrapper { + display: flex; + justify-content: center; + width: 100%; + } + .canvas-container { background: white; box-shadow: 0 2px 12px ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(0, 0, 0, 0.3)')}; @@ -249,7 +263,7 @@ export const viewerStyles = [ display: inline-block; } - #pdf-canvas { + .page-canvas { display: block; image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges;