feat(viewer): Add smooth zoom and scroll animations to viewer

This commit is contained in:
2025-12-11 16:56:30 +00:00
parent 560f4d667a
commit 8a434d3ba9
4 changed files with 107 additions and 19 deletions

View File

@@ -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

View File

@@ -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.'
}

View File

@@ -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.'
}

View File

@@ -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
*/