feat: Add PDF viewer and preview components with styling and functionality

- Implemented DeesPdfViewer for full-featured PDF viewing with toolbar and sidebar navigation.
- Created DeesPdfPreview for lightweight PDF previews.
- Introduced PdfManager for managing PDF document loading and caching.
- Added CanvasPool for efficient canvas management.
- Developed utility functions for performance monitoring and file size formatting.
- Established styles for viewer and preview components to enhance UI/UX.
- Included demo examples for showcasing PDF viewer capabilities.
This commit is contained in:
2025-09-20 11:42:22 +00:00
parent c33ad2e405
commit a61f57db13
14 changed files with 1726 additions and 2 deletions

View File

@@ -0,0 +1,135 @@
export interface PooledCanvas {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
inUse: boolean;
lastUsed: number;
}
export class CanvasPool {
private static pool: PooledCanvas[] = [];
private static maxPoolSize = 20;
private static readonly MIN_CANVAS_SIZE = 256;
private static readonly MAX_CANVAS_SIZE = 4096;
public static acquire(width: number, height: number): PooledCanvas {
// Try to find a suitable canvas from the pool
const suitable = this.pool.find(
(item) => !item.inUse &&
item.canvas.width >= width &&
item.canvas.height >= height &&
item.canvas.width <= width * 1.5 &&
item.canvas.height <= height * 1.5
);
if (suitable) {
suitable.inUse = true;
suitable.lastUsed = Date.now();
// Clear and resize if needed
suitable.canvas.width = width;
suitable.canvas.height = height;
suitable.ctx.clearRect(0, 0, width, height);
return suitable;
}
// Create new canvas if pool not full
if (this.pool.length < this.maxPoolSize) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', {
alpha: true,
desynchronized: true,
}) as CanvasRenderingContext2D;
canvas.width = Math.min(Math.max(width, this.MIN_CANVAS_SIZE), this.MAX_CANVAS_SIZE);
canvas.height = Math.min(Math.max(height, this.MIN_CANVAS_SIZE), this.MAX_CANVAS_SIZE);
const pooledCanvas: PooledCanvas = {
canvas,
ctx,
inUse: true,
lastUsed: Date.now(),
};
this.pool.push(pooledCanvas);
return pooledCanvas;
}
// Evict and reuse least recently used canvas
const lru = this.pool
.filter((item) => !item.inUse)
.sort((a, b) => a.lastUsed - b.lastUsed)[0];
if (lru) {
lru.canvas.width = width;
lru.canvas.height = height;
lru.ctx.clearRect(0, 0, width, height);
lru.inUse = true;
lru.lastUsed = Date.now();
return lru;
}
// Fallback: create temporary canvas (shouldn't normally happen)
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
canvas.width = width;
canvas.height = height;
return {
canvas,
ctx,
inUse: true,
lastUsed: Date.now(),
};
}
public static release(pooledCanvas: PooledCanvas) {
if (this.pool.includes(pooledCanvas)) {
pooledCanvas.inUse = false;
// Clear canvas to free memory
pooledCanvas.ctx.clearRect(0, 0, pooledCanvas.canvas.width, pooledCanvas.canvas.height);
}
}
public static releaseAll() {
for (const item of this.pool) {
item.inUse = false;
item.ctx.clearRect(0, 0, item.canvas.width, item.canvas.height);
}
}
public static destroy() {
for (const item of this.pool) {
item.canvas.width = 0;
item.canvas.height = 0;
}
this.pool = [];
}
public static getStats() {
return {
poolSize: this.pool.length,
maxPoolSize: this.maxPoolSize,
inUse: this.pool.filter((item) => item.inUse).length,
available: this.pool.filter((item) => !item.inUse).length,
};
}
public static adjustPoolSize(newSize: number) {
if (newSize < this.pool.length) {
// Remove excess canvases
const toRemove = this.pool.length - newSize;
const removed = this.pool
.filter((item) => !item.inUse)
.slice(0, toRemove);
for (const item of removed) {
const index = this.pool.indexOf(item);
if (index > -1) {
this.pool.splice(index, 1);
}
}
}
this.maxPoolSize = newSize;
}
}

View File

@@ -0,0 +1,108 @@
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<string, CachedDocument>();
private static maxCacheSize = 10;
private static pdfjsLib: any;
private static initialized = false;
public static async initialize() {
if (this.initialized) return;
// @ts-ignore
this.pdfjsLib = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4.0.379/+esm');
this.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.0.379/build/pdf.worker.mjs';
this.initialized = true;
}
public static async loadDocument(url: string): Promise<any> {
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
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;
}
}
}
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(),
})),
};
}
}

View File

@@ -0,0 +1,98 @@
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: number | undefined;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = window.setTimeout(later, wait);
};
}
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return function executedFunction(...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
export function isInViewport(element: Element, margin = 0): boolean {
const rect = element.getBoundingClientRect();
return (
rect.top >= -margin &&
rect.left >= -margin &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) + margin &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth) + margin
);
}
export class PerformanceMonitor {
private static marks = new Map<string, number>();
private static measures: Array<{ name: string; duration: number }> = [];
public static mark(name: string) {
this.marks.set(name, performance.now());
}
public static measure(name: string, startMark: string) {
const start = this.marks.get(startMark);
if (start) {
const duration = performance.now() - start;
this.measures.push({ name, duration });
this.marks.delete(startMark);
return duration;
}
return 0;
}
public static getReport() {
const report = {
measures: [...this.measures],
averages: {} as Record<string, number>,
};
// Calculate averages for repeated measures
const grouped = new Map<string, number[]>();
for (const measure of this.measures) {
if (!grouped.has(measure.name)) {
grouped.set(measure.name, []);
}
grouped.get(measure.name)!.push(measure.duration);
}
for (const [name, durations] of grouped) {
report.averages[name] = durations.reduce((a, b) => a + b, 0) / durations.length;
}
return report;
}
public static clear() {
this.marks.clear();
this.measures = [];
}
}