This commit is contained in:
2025-07-14 18:13:02 +00:00
parent a4a3c6dc50
commit 01dad7cc5e
4 changed files with 645 additions and 7 deletions

View File

@@ -71,7 +71,21 @@ tap.test('render image lightbox component', async () => {
await lightbox.updateComplete;
expect(lightbox.isOpen).toEqual(true);
console.log('Image lightbox component rendered successfully');
// Test opening with a PDF
await lightbox.open({
url: 'data:application/pdf;base64,JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgovUmVzb3VyY2VzIDw8Ci9Gb250IDw8Ci9GMSA2IDAgUgo+Pgo+Pgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8gV29ybGQpIFRqCkVUClEKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL1RpbWVzLVJvbWFuCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA3NCAwMDAwMCBuIAowMDAwMDAwMTIwIDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDM2NCAwMDAwMCBuIAowMDAwMDAwNDY2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKNTY1CiUlRU9G',
name: 'test.pdf',
type: 'application/pdf',
size: 565
});
await lightbox.updateComplete;
// Check that PDF viewer is rendered
const pdfViewer = lightbox.shadowRoot.querySelector('sio-pdf-viewer');
expect(pdfViewer).toBeTruthy();
console.log('Image lightbox component rendered successfully with both image and PDF support');
document.body.removeChild(lightbox);
});
@@ -106,4 +120,30 @@ tap.test('render dropdown menu component', async () => {
document.body.removeChild(dropdown);
});
tap.test('render pdf viewer component', async () => {
// Create and add PDF viewer
const pdfViewer = new socialioCatalog.SioPdfViewer();
pdfViewer.style.width = '600px';
pdfViewer.style.height = '400px';
pdfViewer.url = 'data:application/pdf;base64,JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgovUmVzb3VyY2VzIDw8Ci9Gb250IDw8Ci9GMSA2IDAgUgo+Pgo+Pgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8gV29ybGQpIFRqCkVUClEKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL1RpbWVzLVJvbWFuCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA3NCAwMDAwMCBuIAowMDAwMDAwMTIwIDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDM2NCAwMDAwMCBuIAowMDAwMDAwNDY2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKNTY1CiUlRU9G';
pdfViewer.fileName = 'test.pdf';
document.body.appendChild(pdfViewer);
await pdfViewer.updateComplete;
expect(pdfViewer).toBeInstanceOf(socialioCatalog.SioPdfViewer);
// Check main elements
const container = pdfViewer.shadowRoot.querySelector('.container');
expect(container).toBeTruthy();
// PDF viewer uses canvas after loading, not iframe
// Just verify the component rendered correctly
expect(pdfViewer.url).toEqual('data:application/pdf;base64,JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgovUmVzb3VyY2VzIDw8Ci9Gb250IDw8Ci9GMSA2IDAgUgo+Pgo+Pgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8gV29ybGQpIFRqCkVUClEKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL1RpbWVzLVJvbWFuCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA3NCAwMDAwMCBuIAowMDAwMDAwMTIwIDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDM2NCAwMDAwMCBuIAowMDAwMDAwNDY2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKNTY1CiUlRU9G');
expect(pdfViewer.fileName).toEqual('test.pdf');
console.log('PDF viewer component rendered successfully');
document.body.removeChild(pdfViewer);
});
tap.start();

View File

@@ -12,3 +12,4 @@ export * from './sio-combox.js';
export * from './sio-fab.js';
export * from './sio-recorder.js';
export * from './sio-image-lightbox.js';
export * from './sio-pdf-viewer.js';

View File

@@ -15,6 +15,10 @@ import { colors, bdTheme } from './00colors.js';
import { spacing, radius, shadows, transitions } from './00tokens.js';
import { fontFamilies } from './00fonts.js';
// Import components
import { SioPdfViewer } from './sio-pdf-viewer.js';
SioPdfViewer;
export interface ILightboxFile {
url: string;
name: string;
@@ -360,14 +364,12 @@ export class SioImageLightbox extends DeesElement {
</div>
` : ''}
${isPDF ? html`
<iframe
<sio-pdf-viewer
class="pdf-viewer ${this.fileLoaded ? 'loaded' : ''}"
src="${this.file.url}"
title="${this.file.name}"
.url="${this.file.url}"
.fileName="${this.file.name}"
@load=${() => this.fileLoaded = true}
@error=${() => this.fileLoaded = false}
@click=${(e: Event) => e.stopPropagation()}
></iframe>
></sio-pdf-viewer>
` : html`
<img
class="image ${this.fileLoaded ? 'loaded' : ''}"
@@ -404,6 +406,12 @@ export class SioImageLightbox extends DeesElement {
this.resetZoom();
this.isOpen = true;
// For PDFs, we'll handle loading state differently since it's in a separate component
if (this.isPDF()) {
// PDFs are handled by sio-pdf-viewer which manages its own loading state
this.fileLoaded = true;
}
// Add keyboard listener
document.addEventListener('keydown', this.handleKeyDown);
}

View File

@@ -0,0 +1,589 @@
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
cssManager,
css,
unsafeCSS,
state,
} from '@design.estate/dees-element';
// Import design tokens
import { colors, bdTheme } from './00colors.js';
import { spacing, radius, shadows, transitions } from './00tokens.js';
import { fontFamilies } from './00fonts.js';
declare global {
interface HTMLElementTagNameMap {
'sio-pdf-viewer': SioPdfViewer;
}
interface Window {
pdfjsLib?: any;
}
}
@customElement('sio-pdf-viewer')
export class SioPdfViewer extends DeesElement {
public static demo = () => html`
<sio-pdf-viewer
.url=${'data:application/pdf;base64,JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgovUmVzb3VyY2VzIDw8Ci9Gb250IDw8Ci9GMSA2IDAgUgo+Pgo+Pgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8gV29ybGQpIFRqCkVUClEKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL1RpbWVzLVJvbWFuCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA3NCAwMDAwMCBuIAowMDAwMDAwMTIwIDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDM2NCAwMDAwMCBuIAowMDAwMDAwNDY2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKNTY1CiUlRU9G'}
.fileName=${'demo.pdf'}
style="width: 600px; height: 800px;"
></sio-pdf-viewer>
`;
@property({ type: String })
public url: string = '';
@property({ type: String })
public fileName: string = 'document.pdf';
@state()
private isLoading: boolean = true;
@state()
private hasError: boolean = false;
@state()
private pdfDocument: any = null;
@state()
private currentPage: number = 1;
@state()
private totalPages: number = 0;
@state()
private scale: number = 1;
private static pdfJsLoaded: boolean = false;
private static pdfJsLoading: Promise<void> | null = null;
private renderTask: any = null;
private resizeObserver: ResizeObserver | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
position: relative;
background: ${bdTheme('background')};
font-family: ${unsafeCSS(fontFamilies.sans)};
}
.container {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
}
.pdf-container {
width: 100%;
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
background: ${bdTheme('muted')};
}
.pdf-canvas-wrapper {
position: relative;
margin: ${unsafeCSS(spacing["4"])} auto;
box-shadow: ${unsafeCSS(shadows.lg)};
background: white;
}
canvas {
display: block;
max-width: 100%;
height: auto;
}
.pdf-controls {
position: sticky;
top: 0;
z-index: 10;
width: 100%;
padding: ${unsafeCSS(spacing["3"])};
background: ${bdTheme('background')};
border-bottom: 1px solid ${bdTheme('border')};
display: flex;
align-items: center;
justify-content: center;
gap: ${unsafeCSS(spacing["3"])};
box-shadow: ${unsafeCSS(shadows.sm)};
}
.pdf-controls-group {
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["2"])};
}
.page-info {
font-size: 0.875rem;
color: ${bdTheme('mutedForeground')};
min-width: 100px;
text-align: center;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: ${bdTheme('mutedForeground')};
}
.spinner {
animation: spin 1s linear infinite;
margin-bottom: ${unsafeCSS(spacing["2"])};
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.error-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
padding: ${unsafeCSS(spacing["6"])};
background: ${bdTheme('card')};
border: 1px solid ${bdTheme('border')};
border-radius: ${unsafeCSS(radius.lg)};
box-shadow: ${unsafeCSS(shadows.md)};
max-width: 400px;
}
.error-icon {
color: ${bdTheme('destructive')};
margin-bottom: ${unsafeCSS(spacing["3"])};
}
.error-title {
font-size: 1.125rem;
font-weight: 600;
color: ${bdTheme('foreground')};
margin-bottom: ${unsafeCSS(spacing["2"])};
}
.error-message {
color: ${bdTheme('mutedForeground')};
margin-bottom: ${unsafeCSS(spacing["4"])};
font-size: 0.875rem;
}
.error-actions {
display: flex;
gap: ${unsafeCSS(spacing["2"])};
justify-content: center;
}
.fallback-viewer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: ${bdTheme('background')};
}
.fallback-header {
padding: ${unsafeCSS(spacing["4"])};
border-bottom: 1px solid ${bdTheme('border')};
display: flex;
align-items: center;
justify-content: space-between;
background: ${bdTheme('card')};
}
.fallback-title {
font-weight: 500;
color: ${bdTheme('foreground')};
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["2"])};
}
.fallback-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: ${unsafeCSS(spacing["8"])};
}
.fallback-message {
text-align: center;
color: ${bdTheme('mutedForeground')};
}
.fallback-icon {
font-size: 48px;
margin-bottom: ${unsafeCSS(spacing["4"])};
opacity: 0.5;
}
.fallback-text {
margin-bottom: ${unsafeCSS(spacing["4"])};
}
/* Scrollbar styling */
.pdf-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.pdf-container::-webkit-scrollbar-track {
background: ${bdTheme('muted')};
}
.pdf-container::-webkit-scrollbar-thumb {
background: ${bdTheme('border')};
border-radius: 4px;
}
.pdf-container::-webkit-scrollbar-thumb:hover {
background: ${bdTheme('mutedForeground')};
}
/* Responsive */
@media (max-width: 600px) {
.pdf-controls {
flex-wrap: wrap;
gap: ${unsafeCSS(spacing["2"])};
}
.pdf-controls-group {
flex-wrap: nowrap;
}
}
`,
];
public render(): TemplateResult {
if (this.hasError) {
return this.renderError();
}
return html`
<div class="container">
${this.isLoading ? html`
<div class="loading">
<sio-icon class="spinner" icon="loader" size="24"></sio-icon>
<div>Loading PDF...</div>
</div>
` : ''}
${this.pdfDocument ? html`
<div class="pdf-controls">
<div class="pdf-controls-group">
<sio-button
type="ghost"
size="sm"
@click=${this.previousPage}
?disabled=${this.currentPage <= 1}
>
<sio-icon icon="chevron-left" size="16"></sio-icon>
</sio-button>
<div class="page-info">
Page ${this.currentPage} of ${this.totalPages}
</div>
<sio-button
type="ghost"
size="sm"
@click=${this.nextPage}
?disabled=${this.currentPage >= this.totalPages}
>
<sio-icon icon="chevron-right" size="16"></sio-icon>
</sio-button>
</div>
<div class="pdf-controls-group">
<sio-button
type="ghost"
size="sm"
@click=${this.zoomOut}
>
<sio-icon icon="zoom-out" size="16"></sio-icon>
</sio-button>
<sio-button
type="ghost"
size="sm"
@click=${this.resetZoom}
>
<sio-icon icon="maximize-2" size="16"></sio-icon>
</sio-button>
<sio-button
type="ghost"
size="sm"
@click=${this.zoomIn}
>
<sio-icon icon="zoom-in" size="16"></sio-icon>
</sio-button>
</div>
<div class="pdf-controls-group">
<sio-button
type="ghost"
size="sm"
@click=${this.downloadPdf}
>
<sio-icon icon="download" size="16"></sio-icon>
</sio-button>
</div>
</div>
<div class="pdf-container">
<div class="pdf-canvas-wrapper">
<canvas></canvas>
</div>
</div>
` : ''}
</div>
`;
}
private renderError(): TemplateResult {
return html`
<div class="error-container">
<sio-icon class="error-icon" icon="alert-circle" size="48"></sio-icon>
<div class="error-title">Unable to display PDF</div>
<div class="error-message">
The PDF viewer couldn't load this document. This might be due to browser restrictions or an invalid PDF file.
</div>
<div class="error-actions">
<sio-button
type="primary"
size="sm"
@click=${this.downloadPdf}
>
<sio-icon icon="download" size="16"></sio-icon>
Download PDF
</sio-button>
<sio-button
type="outline"
size="sm"
@click=${this.openInNewTab}
>
<sio-icon icon="external-link" size="16"></sio-icon>
Open in New Tab
</sio-button>
</div>
</div>
`;
}
public async connectedCallback() {
await super.connectedCallback();
// Set up resize observer for responsive rendering
const container = this.shadowRoot?.querySelector('.pdf-container');
if (container) {
this.resizeObserver = new ResizeObserver(() => {
if (this.pdfDocument && !this.isLoading) {
this.renderPage();
}
});
this.resizeObserver.observe(container);
}
if (this.url) {
await this.loadPdf();
}
}
public async updated(changedProperties: Map<string | number | symbol, unknown>) {
await super.updated(changedProperties);
if (changedProperties.has('url') && this.url) {
await this.loadPdf();
}
// Re-render when scale changes
if (changedProperties.has('scale') && this.pdfDocument && !this.isLoading) {
await this.renderPage();
}
}
private static async loadPdfJs(): Promise<void> {
if (SioPdfViewer.pdfJsLoaded) return;
if (SioPdfViewer.pdfJsLoading) {
return SioPdfViewer.pdfJsLoading;
}
SioPdfViewer.pdfJsLoading = new Promise(async (resolve, reject) => {
try {
// Load PDF.js from jsDelivr
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js';
script.onload = () => {
if (window.pdfjsLib) {
// Configure worker
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
SioPdfViewer.pdfJsLoaded = true;
resolve();
} else {
reject(new Error('PDF.js failed to load'));
}
};
script.onerror = () => reject(new Error('Failed to load PDF.js script'));
document.head.appendChild(script);
} catch (error) {
reject(error);
}
});
return SioPdfViewer.pdfJsLoading;
}
private async loadPdf() {
this.isLoading = true;
this.hasError = false;
this.pdfDocument = null;
try {
// Load PDF.js if not already loaded
await SioPdfViewer.loadPdfJs();
// Load the PDF document
const loadingTask = window.pdfjsLib.getDocument({
url: this.url,
// Enable range requests for better performance
disableRange: false,
// Enable streaming for large PDFs
disableStream: false,
});
this.pdfDocument = await loadingTask.promise;
this.totalPages = this.pdfDocument.numPages;
this.currentPage = 1;
this.isLoading = false;
// Render the first page
await this.renderPage();
} catch (error) {
console.error('Failed to load PDF:', error);
this.hasError = true;
this.isLoading = false;
}
}
private async renderPage() {
if (!this.pdfDocument) return;
// Cancel any ongoing render task
if (this.renderTask) {
this.renderTask.cancel();
}
try {
const page = await this.pdfDocument.getPage(this.currentPage);
const canvas = this.shadowRoot?.querySelector('canvas') as HTMLCanvasElement;
if (!canvas) return;
const context = canvas.getContext('2d');
const viewport = page.getViewport({ scale: this.scale });
// Set canvas dimensions
canvas.height = viewport.height;
canvas.width = viewport.width;
// Render PDF page into canvas context
const renderContext = {
canvasContext: context,
viewport: viewport
};
this.renderTask = page.render(renderContext);
await this.renderTask.promise;
} catch (error) {
if (error.name !== 'RenderingCancelledException') {
console.error('Error rendering page:', error);
}
}
}
private async previousPage() {
if (this.currentPage > 1) {
this.currentPage--;
await this.renderPage();
}
}
private async nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
await this.renderPage();
}
}
private async zoomIn() {
this.scale = Math.min(this.scale * 1.2, 3);
await this.renderPage();
}
private async zoomOut() {
this.scale = Math.max(this.scale / 1.2, 0.5);
await this.renderPage();
}
private async resetZoom() {
this.scale = 1;
await this.renderPage();
}
private downloadPdf() {
const a = document.createElement('a');
a.href = this.url;
a.download = this.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
private openInNewTab() {
window.open(this.url, '_blank');
}
public async disconnectedCallback() {
await super.disconnectedCallback();
// Clean up resize observer
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// Cancel any ongoing render task
if (this.renderTask) {
this.renderTask.cancel();
}
// Destroy PDF document to free memory
if (this.pdfDocument) {
this.pdfDocument.destroy();
}
}
}