From 8a434d3ba9af226c686d31278d0eb76dd6ea67af Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 11 Dec 2025 16:56:30 +0000 Subject: [PATCH] feat(viewer): Add smooth zoom and scroll animations to viewer --- changelog.md | 9 +++ ts/00_commitinfo_data.ts | 2 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/viewer.ts | 113 +++++++++++++++++++++++++++++------ 4 files changed, 107 insertions(+), 19 deletions(-) diff --git a/changelog.md b/changelog.md index afbb6e4..bd659b9 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-12-11 - 2.1.0 - feat(viewer) +Add smooth zoom and scroll animations to viewer + +- Introduce smooth zoom animation: new private animateZoomTo(targetZoom, duration) method and zoomAnimationId to track/cancel running animations +- Animate transitions for numeric zoom presets and zoom step changes (handleZoomStep now uses animateZoomTo) +- Cancel ongoing zoom animation when user manipulates the zoom slider +- Make fit-to-width and fit-to-page calculations optionally animate (calculateFitWidth/ calculateFitPage accept animate flag) +- Add animated scrolling helper animateScrollTo for smooth page scrolling when requested (used when smooth=true) + ## 2025-12-11 - 2.0.6 - fix(DeDocumentViewer) Account for toolbar and padding when calculating Fit Page zoom in viewer diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 2a0c9e5..e9826ac 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-document', - version: '2.0.6', + version: '2.1.0', description: 'A sophisticated framework for dynamically generating and rendering business documents like invoices with modern web technologies, featuring PDF creation, templating, and automation.' } diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 2a0c9e5..e9826ac 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-document', - version: '2.0.6', + version: '2.1.0', description: 'A sophisticated framework for dynamically generating and rendering business documents like invoices with modern web technologies, featuring PDF creation, templating, and automation.' } diff --git a/ts_web/elements/viewer.ts b/ts_web/elements/viewer.ts index c7a2fcc..94a01a0 100644 --- a/ts_web/elements/viewer.ts +++ b/ts_web/elements/viewer.ts @@ -105,6 +105,9 @@ export class DeDocumentViewer extends DeesElement { @state() accessor thumbnailPages: number[] = []; + // Zoom animation + private zoomAnimationId: number | null = null; + public static styles = [ cssManager.defaultStyles, css` @@ -905,31 +908,69 @@ export class DeDocumentViewer extends DeesElement { this.updateDisplayZoom(); } else if (value === "fit-width") { // Calculate zoom to fit page width to viewport width - this.calculateFitWidth(); + this.calculateFitWidth(true); } else if (value === "fit-page") { // Calculate zoom to fit entire page in viewport - this.calculateFitPage(); + this.calculateFitPage(true); } else { - this.zoomLevel = value; - this.displayZoom = value; + // Animate to numeric preset + this.animateZoomTo(value); } } private handleZoomStep(delta: number): void { const current = this.zoomLevel ?? this.displayZoom; const newZoom = Math.min(400, Math.max(25, current + delta)); - this.zoomLevel = newZoom; - this.displayZoom = newZoom; + this.animateZoomTo(newZoom); this.zoomMode = newZoom; } private handleZoomSlider(e: Event): void { + // Cancel any running animation when user takes manual control + if (this.zoomAnimationId !== null) { + cancelAnimationFrame(this.zoomAnimationId); + this.zoomAnimationId = null; + } const value = parseInt((e.target as HTMLInputElement).value, 10); this.zoomLevel = value; this.displayZoom = value; this.zoomMode = value; } + /** + * Animate zoom level to target value + * @param targetZoom - Target zoom percentage + * @param duration - Animation duration in ms (default: 200) + */ + private animateZoomTo(targetZoom: number, duration: number = 200): void { + // Cancel any existing animation + if (this.zoomAnimationId !== null) { + cancelAnimationFrame(this.zoomAnimationId); + } + + const startZoom = this.zoomLevel ?? this.displayZoom; + const startTime = performance.now(); + const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const easedProgress = easeOutCubic(progress); + + const currentZoom = startZoom + (targetZoom - startZoom) * easedProgress; + this.zoomLevel = Math.round(currentZoom); + this.displayZoom = this.zoomLevel; + + if (progress < 1) { + this.zoomAnimationId = requestAnimationFrame(animate); + } else { + this.zoomAnimationId = null; + } + }; + + this.zoomAnimationId = requestAnimationFrame(animate); + } + private handleSpacingPreset(value: number): void { this.pageGap = value; } @@ -1083,19 +1124,24 @@ export class DeDocumentViewer extends DeesElement { setTimeout(cleanup, 100); } - private calculateFitWidth(): void { + private calculateFitWidth(animate: boolean = false): void { const viewport = this.shadowRoot?.querySelector(".viewport"); if (!viewport) return; // Account for padding and scrollbar width const viewportWidth = viewport.clientWidth - 32 - 16; const scale = viewportWidth / plugins.shared.A4_WIDTH; + const targetZoom = Math.round(scale * 100); - this.zoomLevel = Math.round(scale * 100); - this.displayZoom = this.zoomLevel; + if (animate) { + this.animateZoomTo(targetZoom); + } else { + this.zoomLevel = targetZoom; + this.displayZoom = targetZoom; + } } - private calculateFitPage(): void { + private calculateFitPage(animate: boolean = false): void { const viewport = this.shadowRoot?.querySelector(".viewport"); if (!viewport) return; @@ -1110,9 +1156,14 @@ export class DeDocumentViewer extends DeesElement { const scaleByHeight = viewportHeight / plugins.shared.A4_HEIGHT; const scaleByWidth = viewportWidth / plugins.shared.A4_WIDTH; const scale = Math.min(scaleByHeight, scaleByWidth); + const targetZoom = Math.round(scale * 100); - this.zoomLevel = Math.round(scale * 100); - this.displayZoom = this.zoomLevel; + if (animate) { + this.animateZoomTo(targetZoom); + } else { + this.zoomLevel = targetZoom; + this.displayZoom = targetZoom; + } } private updateDisplayZoom(): void { @@ -1297,14 +1348,42 @@ export class DeDocumentViewer extends DeesElement { const pageRect = targetPage.getBoundingClientRect(); const pageOffsetFromDoc = pageRect.top - docRect.top; - // Scroll to the page position - viewport.scrollTo({ - top: pageOffsetFromDoc, - behavior: smooth ? "smooth" : "instant", - }); + // Scroll to the page position with fast custom animation + if (smooth) { + this.animateScrollTo(viewport, pageOffsetFromDoc, 250); + } else { + viewport.scrollTop = pageOffsetFromDoc; + } } } + /** + * Animate scroll to target position + * @param element - The scrollable element + * @param targetTop - Target scroll position + * @param duration - Animation duration in ms (default: 150) + */ + private animateScrollTo(element: Element, targetTop: number, duration: number = 150): void { + const startTop = element.scrollTop; + const distance = targetTop - startTop; + const startTime = performance.now(); + const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); + + const step = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const easedProgress = easeOutCubic(progress); + + element.scrollTop = startTop + distance * easedProgress; + + if (progress < 1) { + requestAnimationFrame(step); + } + }; + + requestAnimationFrame(step); + } + /** * Trigger the print dialog */