feat(components): add large set of new UI components and demos, reorganize groups, and bump a few dependencies

This commit is contained in:
2026-01-27 10:57:42 +00:00
parent 8158b791c7
commit 162688cdb5
218 changed files with 5223 additions and 458 deletions

View File

@@ -0,0 +1,611 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
property,
state,
cssManager,
} from '@design.estate/dees-element';
import '../../00group-utility/dees-icon/dees-icon.js';
import { demo } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-audio-viewer': DeesAudioViewer;
}
}
@customElement('dees-audio-viewer')
export class DeesAudioViewer extends DeesElement {
public static demo = demo;
public static demoGroups = ['Media'];
@property()
accessor src: string = '';
@property()
accessor title: string = '';
@property()
accessor artist: string = '';
@property({ type: Boolean })
accessor showWaveform: boolean = true;
@property({ type: Boolean })
accessor autoplay: boolean = false;
@property({ type: Boolean })
accessor loop: boolean = false;
@state()
accessor isPlaying: boolean = false;
@state()
accessor currentTime: number = 0;
@state()
accessor duration: number = 0;
@state()
accessor volume: number = 1;
@state()
accessor isMuted: boolean = false;
@state()
accessor loading: boolean = false;
@state()
accessor error: string = '';
@state()
accessor waveformData: number[] = [];
@state()
accessor waveformReady: boolean = false;
private audioElement: HTMLAudioElement | null = null;
private canvasElement: HTMLCanvasElement | null = null;
private animFrameId: number = 0;
private volumeBeforeMute: number = 1;
public render(): TemplateResult {
const titleText = this.title && this.artist
? `${this.title}${this.artist}`
: this.title || this.artist || '';
return html`
<style>
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.viewer-container {
display: flex;
flex-direction: column;
height: 100%;
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(215 20% 10%)')};
}
.toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 0 16px;
height: 48px;
background: ${cssManager.bdTheme('#ffffff', 'hsl(215 20% 15%)')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', 'hsl(217 25% 22%)')};
flex-shrink: 0;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.toolbar-group--end {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.toolbar-button dees-icon {
font-size: 16px;
}
.toolbar-button:hover {
background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 25% 22%)')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.toolbar-button:active {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.toolbar-button.active {
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
}
.toolbar-title {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.time-display {
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
min-width: 90px;
flex-shrink: 0;
}
.volume-group {
display: flex;
align-items: center;
gap: 4px;
}
.volume-slider {
width: 70px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 22%)')};
border-radius: 2px;
outline: none;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
border: none;
cursor: pointer;
}
.content-area {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.waveform-container {
position: absolute;
inset: 0;
cursor: pointer;
overflow: hidden;
}
.waveform-container canvas {
width: 100%;
height: 100%;
display: block;
}
.seekbar-container {
width: 80%;
max-width: 600px;
height: 6px;
cursor: pointer;
border-radius: 3px;
background: ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 22%)')};
overflow: hidden;
}
.seekbar-fill {
height: 100%;
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
border-radius: 3px;
transition: width 0.1s linear;
}
.error-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.error-overlay .error-icon {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
font-size: 32px;
}
.error-text {
font-size: 13px;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 28%)')};
border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
<div class="viewer-container">
<div class="toolbar">
<div class="toolbar-group">
<button class="toolbar-button" @click=${this.togglePlay}>
<dees-icon icon="lucide:${this.isPlaying ? 'Pause' : 'Play'}"></dees-icon>
</button>
<span class="time-display">
${this.formatTime(this.currentTime)} / ${this.formatTime(this.duration)}
</span>
</div>
${titleText ? html`
<span class="toolbar-title">${titleText}</span>
` : ''}
<div class="toolbar-group--end">
<button
class="toolbar-button ${this.loop ? 'active' : ''}"
@click=${this.toggleLoop}
title="Loop"
>
<dees-icon icon="lucide:Repeat"></dees-icon>
</button>
<div class="volume-group">
<button class="toolbar-button" @click=${this.toggleMute} title="${this.isMuted ? 'Unmute' : 'Mute'}">
<dees-icon icon="lucide:${this.isMuted || this.volume === 0 ? 'VolumeX' : this.volume < 0.5 ? 'Volume1' : 'Volume2'}"></dees-icon>
</button>
<input
class="volume-slider"
type="range"
min="0"
max="1"
step="0.01"
.value=${String(this.isMuted ? 0 : this.volume)}
@input=${this.handleVolumeChange}
/>
</div>
</div>
</div>
<div class="content-area">
${this.error ? html`
<div class="error-overlay">
<dees-icon class="error-icon" icon="lucide:MusicOff"></dees-icon>
<span class="error-text">${this.error}</span>
</div>
` : this.loading ? html`
<div class="loading-overlay">
<div class="loading-spinner"></div>
</div>
` : this.showWaveform ? html`
<div class="waveform-container" @click=${this.handleWaveformClick}>
<canvas></canvas>
</div>
` : html`
<div class="seekbar-container" @click=${this.handleSeekbarClick}>
<div class="seekbar-fill" style="width: ${this.duration ? (this.currentTime / this.duration) * 100 : 0}%"></div>
</div>
`}
</div>
</div>
`;
}
public async connectedCallback(): Promise<void> {
await super.connectedCallback();
if (this.src) {
this.initAudio();
}
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
this.cleanup();
}
public async updated(changedProperties: Map<PropertyKey, unknown>): Promise<void> {
super.updated(changedProperties);
if (changedProperties.has('src') && this.src) {
this.cleanup();
this.initAudio();
}
if (changedProperties.has('waveformData') || changedProperties.has('currentTime')) {
this.drawWaveform();
}
}
public play(): void {
this.audioElement?.play();
}
public pause(): void {
this.audioElement?.pause();
}
public togglePlay(): void {
if (this.isPlaying) {
this.pause();
} else {
this.play();
}
}
public seek(time: number): void {
if (this.audioElement) {
this.audioElement.currentTime = time;
}
}
public setVolume(v: number): void {
this.volume = Math.max(0, Math.min(1, v));
if (this.audioElement) {
this.audioElement.volume = this.volume;
}
if (this.volume > 0) {
this.isMuted = false;
}
}
public toggleMute(): void {
if (this.isMuted) {
this.isMuted = false;
this.volume = this.volumeBeforeMute || 0.5;
if (this.audioElement) {
this.audioElement.volume = this.volume;
}
} else {
this.volumeBeforeMute = this.volume;
this.isMuted = true;
if (this.audioElement) {
this.audioElement.volume = 0;
}
}
}
private toggleLoop(): void {
this.loop = !this.loop;
if (this.audioElement) {
this.audioElement.loop = this.loop;
}
}
private initAudio(): void {
this.audioElement = new Audio();
this.audioElement.crossOrigin = 'anonymous';
this.audioElement.src = this.src;
this.audioElement.volume = this.isMuted ? 0 : this.volume;
this.audioElement.loop = this.loop;
this.audioElement.addEventListener('loadedmetadata', () => {
this.duration = this.audioElement!.duration;
this.loading = false;
});
this.audioElement.addEventListener('play', () => {
this.isPlaying = true;
this.startTimeUpdate();
});
this.audioElement.addEventListener('pause', () => {
this.isPlaying = false;
this.stopTimeUpdate();
});
this.audioElement.addEventListener('ended', () => {
this.isPlaying = false;
this.stopTimeUpdate();
});
this.audioElement.addEventListener('error', () => {
this.error = 'Failed to load audio';
this.loading = false;
});
this.audioElement.addEventListener('timeupdate', () => {
this.currentTime = this.audioElement!.currentTime;
});
if (this.autoplay) {
this.audioElement.play().catch(() => {
// Autoplay blocked by browser
});
}
if (this.showWaveform) {
this.loadWaveform();
}
}
private async loadWaveform(): Promise<void> {
try {
this.loading = true;
const response = await fetch(this.src);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const channelData = audioBuffer.getChannelData(0);
const bars = 200;
const blockSize = Math.floor(channelData.length / bars);
const waveform: number[] = [];
for (let i = 0; i < bars; i++) {
let sum = 0;
for (let j = 0; j < blockSize; j++) {
sum += Math.abs(channelData[i * blockSize + j]);
}
waveform.push(sum / blockSize);
}
// Normalize
const max = Math.max(...waveform);
this.waveformData = waveform.map((v) => (max > 0 ? v / max : 0));
this.waveformReady = true;
this.loading = false;
await audioContext.close();
} catch {
this.waveformReady = false;
this.loading = false;
}
}
private drawWaveform(): void {
if (!this.showWaveform || !this.waveformReady) return;
const canvas = this.shadowRoot?.querySelector('canvas') as HTMLCanvasElement;
if (!canvas) return;
this.canvasElement = canvas;
const container = canvas.parentElement!;
const dpr = window.devicePixelRatio || 1;
const width = container.clientWidth;
const height = container.clientHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height);
const bars = this.waveformData.length;
if (bars === 0) return;
const barWidth = width / bars;
const playedRatio = this.duration > 0 ? this.currentTime / this.duration : 0;
const playedBars = Math.floor(playedRatio * bars);
const isDark = document.body.classList.contains('theme-dark') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
const playedColor = isDark ? 'hsl(213 93% 68%)' : 'hsl(217 91% 60%)';
const unplayedColor = isDark ? 'hsl(217 25% 22%)' : 'hsl(214 31% 86%)';
for (let i = 0; i < bars; i++) {
const amplitude = this.waveformData[i];
const barHeight = Math.max(2, amplitude * (height - 4));
const x = i * barWidth;
const y = (height - barHeight) / 2;
ctx.fillStyle = i < playedBars ? playedColor : unplayedColor;
ctx.fillRect(x + 0.5, y, barWidth - 1, barHeight);
}
}
private handleWaveformClick(e: MouseEvent): void {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
this.seek(ratio * this.duration);
}
private handleSeekbarClick(e: MouseEvent): void {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
this.seek(ratio * this.duration);
}
private handleVolumeChange(e: Event): void {
const value = parseFloat((e.target as HTMLInputElement).value);
this.setVolume(value);
}
private startTimeUpdate(): void {
this.stopTimeUpdate();
const update = () => {
if (this.audioElement && this.isPlaying) {
this.currentTime = this.audioElement.currentTime;
this.animFrameId = requestAnimationFrame(update);
}
};
this.animFrameId = requestAnimationFrame(update);
}
private stopTimeUpdate(): void {
if (this.animFrameId) {
cancelAnimationFrame(this.animFrameId);
this.animFrameId = 0;
}
}
private formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
private cleanup(): void {
this.stopTimeUpdate();
if (this.audioElement) {
this.audioElement.pause();
this.audioElement.src = '';
this.audioElement = null;
}
this.isPlaying = false;
this.currentTime = 0;
this.duration = 0;
this.waveformData = [];
this.waveformReady = false;
}
}

View File

@@ -0,0 +1,69 @@
import { html, cssManager } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 48px;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
display: flex;
flex-direction: column;
gap: 32px;
}
.section {
max-width: 600px;
width: 100%;
margin: 0 auto;
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.section-description {
font-size: 14px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-bottom: 16px;
}
dees-audio-viewer {
height: 200px;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 8px;
overflow: hidden;
}
</style>
<div class="demo-container">
<div class="section">
<div class="section-title">Audio with Waveform</div>
<div class="section-description">Audio player with waveform visualization and full transport controls.</div>
<dees-audio-viewer
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
title="SoundHelix Song 1"
artist="T. Schuerger"
></dees-audio-viewer>
</div>
<div class="section">
<div class="section-title">Audio without Waveform</div>
<div class="section-description">Simple audio player with a seekbar instead of a waveform.</div>
<dees-audio-viewer
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3"
title="SoundHelix Song 2"
.showWaveform=${false}
></dees-audio-viewer>
</div>
<div class="section">
<div class="section-title">Minimal Audio Player</div>
<div class="section-description">No title or artist metadata — just the player.</div>
<dees-audio-viewer
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3"
></dees-audio-viewer>
</div>
</div>
`;

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,410 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
property,
state,
cssManager,
} from '@design.estate/dees-element';
import '../../00group-utility/dees-icon/dees-icon.js';
import { demo } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-image-viewer': DeesImageViewer;
}
}
@customElement('dees-image-viewer')
export class DeesImageViewer extends DeesElement {
public static demo = demo;
public static demoGroups = ['Media'];
@property()
accessor src: string = '';
@property()
accessor alt: string = '';
@property()
accessor fit: 'contain' | 'cover' | 'actual' = 'contain';
@property({ type: Boolean })
accessor showToolbar: boolean = true;
@state()
accessor zoom: number = 1;
@state()
accessor panX: number = 0;
@state()
accessor panY: number = 0;
@state()
accessor isDragging: boolean = false;
@state()
accessor loading: boolean = true;
@state()
accessor error: string = '';
@state()
accessor imageNaturalWidth: number = 0;
@state()
accessor imageNaturalHeight: number = 0;
private dragStartX = 0;
private dragStartY = 0;
private dragStartPanX = 0;
private dragStartPanY = 0;
public render(): TemplateResult {
return html`
<style>
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.viewer-container {
display: flex;
flex-direction: column;
height: 100%;
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(215 20% 10%)')};
}
.toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 0 16px;
height: 48px;
background: ${cssManager.bdTheme('#ffffff', 'hsl(215 20% 15%)')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', 'hsl(217 25% 22%)')};
flex-shrink: 0;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.toolbar-button dees-icon {
font-size: 16px;
}
.toolbar-button:hover {
background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 25% 22%)')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.toolbar-button:active {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.zoom-level {
font-size: 13px;
font-weight: 500;
min-width: 48px;
text-align: center;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
cursor: pointer;
}
.image-area {
flex: 1;
position: relative;
overflow: hidden;
cursor: ${this.zoom > 1 ? (this.isDragging ? 'grabbing' : 'grab') : 'default'};
}
.checkerboard {
position: absolute;
inset: 0;
background-image:
linear-gradient(45deg, ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')} 25%, transparent 25%),
linear-gradient(-45deg, ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')} 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')} 75%),
linear-gradient(-45deg, transparent 75%, ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')} 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
opacity: 0.3;
}
.image-wrapper {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transform: translate(${this.panX}px, ${this.panY}px) scale(${this.zoom});
transition: ${this.isDragging ? 'none' : 'transform 0.2s ease'};
will-change: transform;
}
.image-wrapper img {
max-width: 100%;
max-height: 100%;
object-fit: ${this.fit};
user-select: none;
-webkit-user-drag: none;
}
.image-wrapper img.actual {
max-width: none;
max-height: none;
object-fit: none;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: ${cssManager.bdTheme('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.6)')};
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.error-overlay .error-icon {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
font-size: 32px;
}
.error-text {
font-size: 13px;
}
.image-info {
font-size: 11px;
color: ${cssManager.bdTheme('#a1a1aa', '#71717a')};
padding: 0 4px;
}
</style>
<div class="viewer-container">
${this.showToolbar ? html`
<div class="toolbar">
<div class="toolbar-group">
<button class="toolbar-button" @click=${this.zoomOut} title="Zoom out">
<dees-icon icon="lucide:ZoomOut"></dees-icon>
</button>
<button class="toolbar-button" @click=${this.resetZoom}>
<span class="zoom-level">${Math.round(this.zoom * 100)}%</span>
</button>
<button class="toolbar-button" @click=${this.zoomIn} title="Zoom in">
<dees-icon icon="lucide:ZoomIn"></dees-icon>
</button>
</div>
<div class="toolbar-group">
<button class="toolbar-button" @click=${this.fitToScreen} title="Fit to screen">
<dees-icon icon="lucide:Maximize"></dees-icon>
</button>
<button class="toolbar-button" @click=${this.actualSize} title="Actual size (100%)">
<dees-icon icon="lucide:Scan"></dees-icon>
</button>
</div>
<div class="toolbar-group">
<button class="toolbar-button" @click=${this.download} title="Download">
<dees-icon icon="lucide:Download"></dees-icon>
</button>
</div>
${this.imageNaturalWidth > 0 ? html`
<div class="toolbar-group">
<span class="image-info">${this.imageNaturalWidth} x ${this.imageNaturalHeight}</span>
</div>
` : ''}
</div>
` : ''}
<div
class="image-area"
@wheel=${this.handleWheel}
@mousedown=${this.handleMouseDown}
@mousemove=${this.handleMouseMove}
@mouseup=${this.handleMouseUp}
@mouseleave=${this.handleMouseUp}
@dblclick=${this.handleDoubleClick}
>
<div class="checkerboard"></div>
<div class="image-wrapper">
${this.src ? html`
<img
class="${this.fit === 'actual' ? 'actual' : ''}"
src="${this.src}"
alt="${this.alt}"
@load=${this.handleImageLoad}
@error=${this.handleImageError}
draggable="false"
/>
` : ''}
</div>
${this.loading && this.src ? html`
<div class="loading-overlay">
<div class="loading-spinner"></div>
</div>
` : ''}
${this.error ? html`
<div class="error-overlay">
<dees-icon class="error-icon" icon="lucide:ImageOff"></dees-icon>
<span class="error-text">${this.error}</span>
</div>
` : ''}
</div>
</div>
`;
}
public zoomIn(): void {
this.zoom = Math.min(10, this.zoom * 1.25);
}
public zoomOut(): void {
this.zoom = Math.max(0.1, this.zoom / 1.25);
if (this.zoom <= 1) {
this.panX = 0;
this.panY = 0;
}
}
public resetZoom(): void {
this.zoom = 1;
this.panX = 0;
this.panY = 0;
}
public fitToScreen(): void {
this.zoom = 1;
this.panX = 0;
this.panY = 0;
this.fit = 'contain';
}
public actualSize(): void {
this.zoom = 1;
this.panX = 0;
this.panY = 0;
this.fit = 'actual';
}
public download(): void {
if (!this.src) return;
const link = document.createElement('a');
link.href = this.src;
link.download = this.src.split('/').pop() || 'image';
link.click();
}
private handleImageLoad(e: Event): void {
const img = e.target as HTMLImageElement;
this.loading = false;
this.error = '';
this.imageNaturalWidth = img.naturalWidth;
this.imageNaturalHeight = img.naturalHeight;
}
private handleImageError(): void {
this.loading = false;
this.error = 'Failed to load image';
}
private handleWheel(e: WheelEvent): void {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.min(10, Math.max(0.1, this.zoom * delta));
this.zoom = newZoom;
if (this.zoom <= 1) {
this.panX = 0;
this.panY = 0;
}
}
private handleMouseDown(e: MouseEvent): void {
if (this.zoom <= 1) return;
this.isDragging = true;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragStartPanX = this.panX;
this.dragStartPanY = this.panY;
}
private handleMouseMove(e: MouseEvent): void {
if (!this.isDragging) return;
this.panX = this.dragStartPanX + (e.clientX - this.dragStartX);
this.panY = this.dragStartPanY + (e.clientY - this.dragStartY);
}
private handleMouseUp(): void {
this.isDragging = false;
}
private handleDoubleClick(): void {
if (this.zoom === 1) {
this.zoom = 2;
} else {
this.zoom = 1;
this.panX = 0;
this.panY = 0;
}
}
public updated(changedProperties: Map<PropertyKey, unknown>): void {
super.updated(changedProperties);
if (changedProperties.has('src')) {
this.loading = true;
this.error = '';
this.zoom = 1;
this.panX = 0;
this.panY = 0;
this.imageNaturalWidth = 0;
this.imageNaturalHeight = 0;
}
}
}

View File

@@ -0,0 +1,84 @@
import { html, cssManager } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 48px;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
display: flex;
flex-direction: column;
gap: 32px;
}
.section {
max-width: 900px;
width: 100%;
margin: 0 auto;
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.section-description {
font-size: 14px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-bottom: 16px;
}
dees-image-viewer {
height: 400px;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 8px;
overflow: hidden;
}
.compact {
height: 250px;
}
</style>
<div class="demo-container">
<div class="section">
<div class="section-title">JPEG Image with Toolbar</div>
<div class="section-description">A landscape photo with zoom, pan, fit, and download controls.</div>
<dees-image-viewer
src="https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1200"
alt="Mountain landscape"
></dees-image-viewer>
</div>
<div class="section">
<div class="section-title">PNG with Transparency</div>
<div class="section-description">Transparent PNG displayed on a checkerboard background.</div>
<dees-image-viewer
src="https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png"
alt="PNG transparency demo"
></dees-image-viewer>
</div>
<div class="section">
<div class="section-title">SVG Image</div>
<div class="section-description">Scalable vector graphic.</div>
<dees-image-viewer
src="https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg"
alt="SVG logo"
fit="contain"
></dees-image-viewer>
</div>
<div class="section">
<div class="section-title">No Toolbar Variant</div>
<div class="section-description">Image viewer with the toolbar hidden.</div>
<dees-image-viewer
class="compact"
src="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=800"
alt="Nature scene"
.showToolbar=${false}
></dees-image-viewer>
</div>
</div>
`;

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,24 @@
import { customElement } from '@design.estate/dees-element';
import { DeesTilePdf } from '../dees-tile-pdf/component.js';
declare global {
interface HTMLElementTagNameMap {
'dees-pdf-preview': DeesPdfPreview;
}
}
/**
* @deprecated Use <dees-tile-pdf> instead. This component will be removed in a future release.
*/
@customElement('dees-pdf-preview')
export class DeesPdfPreview extends DeesTilePdf {
public static demoGroups: never[] = []; // Hide from demo catalog
public connectedCallback(): Promise<void> {
console.warn(
'[dees-pdf-preview] is deprecated. Use <dees-tile-pdf> instead. ' +
'This component will be removed in a future release.'
);
return super.connectedCallback();
}
}

View File

@@ -0,0 +1,189 @@
import { html } from '@design.estate/dees-element';
export const demo = () => {
const samplePdfs = [
'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf',
'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf',
];
const generateGridItems = (count: number) => {
const items = [];
for (let i = 0; i < count; i++) {
const pdfUrl = samplePdfs[i % samplePdfs.length];
items.push(html`
<dees-pdf-preview
pdfUrl="${pdfUrl}"
maxPages="3"
stackOffset="6"
clickable="true"
grid-mode
@pdf-preview-click=${(e: CustomEvent) => {
console.log('PDF Preview clicked:', e.detail);
alert(`PDF clicked: ${e.detail.pageCount} pages`);
}}
></dees-pdf-preview>
`);
}
return items;
};
return html`
<style>
.demo-container {
padding: 40px;
background: #f5f5f5;
}
.demo-section {
margin-bottom: 60px;
}
h3 {
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 24px;
}
.preview-row {
display: flex;
gap: 24px;
align-items: center;
margin-bottom: 20px;
}
.preview-label {
font-size: 14px;
font-weight: 500;
min-width: 100px;
}
.performance-stats {
margin-top: 20px;
padding: 16px;
background: white;
border-radius: 8px;
font-size: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
margin-top: 12px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: #666;
}
.stat-value {
font-size: 16px;
font-weight: 600;
}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Single PDF Preview with Stacked Pages</h3>
<dees-pdf-preview
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
maxPages="3"
stackOffset="8"
clickable="true"
></dees-pdf-preview>
</div>
<div class="demo-section">
<h3>Different Sizes</h3>
<div class="preview-row">
<div class="preview-label">Small:</div>
<dees-pdf-preview
size="small"
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"
maxPages="2"
stackOffset="6"
clickable="true"
></dees-pdf-preview>
</div>
<div class="preview-row">
<div class="preview-label">Default:</div>
<dees-pdf-preview
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"
maxPages="3"
stackOffset="8"
clickable="true"
></dees-pdf-preview>
</div>
<div class="preview-row">
<div class="preview-label">Large:</div>
<dees-pdf-preview
size="large"
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"
maxPages="4"
stackOffset="10"
clickable="true"
></dees-pdf-preview>
</div>
</div>
<div class="demo-section">
<h3>Non-Clickable Preview</h3>
<dees-pdf-preview
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"
maxPages="3"
stackOffset="8"
clickable="false"
></dees-pdf-preview>
</div>
<div class="demo-section">
<h3>Performance Grid - 50 PDFs with Lazy Loading</h3>
<p style="margin-bottom: 20px; font-size: 14px; color: #666;">
This grid demonstrates the performance optimizations with 50 PDF previews.
Scroll to see lazy loading in action - previews render only when visible.
</p>
<div class="preview-grid">
${generateGridItems(50)}
</div>
<div class="performance-stats">
<h4>Performance Features</h4>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Lazy Loading</span>
<span class="stat-value">✓ Enabled</span>
</div>
<div class="stat-item">
<span class="stat-label">Canvas Pooling</span>
<span class="stat-value">✓ Active</span>
</div>
<div class="stat-item">
<span class="stat-label">Memory Management</span>
<span class="stat-value">✓ Optimized</span>
</div>
<div class="stat-item">
<span class="stat-label">Intersection Observer</span>
<span class="stat-value">200px margin</span>
</div>
</div>
</div>
</div>
</div>
`;
};

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,223 @@
import { css, cssManager } from '@design.estate/dees-element';
export const previewStyles = [
cssManager.defaultStyles,
css`
:host {
display: inline-block;
position: relative;
}
.preview-container {
position: relative;
width: 200px;
height: 260px;
background: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(215 20% 14%)')};
border-radius: 4px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 1px 3px ${cssManager.bdTheme('rgba(0, 0, 0, 0.12)', 'rgba(0, 0, 0, 0.24)')};
}
.preview-container.clickable {
cursor: pointer;
}
.preview-container.clickable:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px ${cssManager.bdTheme('rgba(0, 0, 0, 0.12)', 'rgba(0, 0, 0, 0.3)')};
}
.preview-container.clickable:hover .preview-overlay {
opacity: 1;
}
.preview-stack {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
overflow: hidden;
}
.preview-stack.non-a4 {
padding: 12px;
}
.preview-canvas {
position: relative;
background: white;
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
image-rendering: auto;
-webkit-font-smoothing: antialiased;
box-shadow: 0 1px 3px ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(0, 0, 0, 0.3)')};
}
.non-a4 .preview-canvas {
border: 1px solid ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 25% 24%)')};
border-radius: 4px;
}
.preview-info {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
padding: 6px 10px;
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.92)', 'hsl(215 20% 12% / 0.92)')};
border-radius: 6px;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
backdrop-filter: blur(12px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.preview-info dees-icon {
font-size: 13px;
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
}
.preview-pages {
font-weight: 500;
font-size: 11px;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.7)', 'rgba(0, 0, 0, 0.8)')};
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 20;
}
.preview-overlay dees-icon {
font-size: 24px;
color: white;
}
.preview-overlay span {
font-size: 14px;
font-weight: 500;
color: white;
}
.preview-loading,
.preview-error {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
}
.preview-loading {
background: ${cssManager.bdTheme('hsl(0 0% 99%)', 'hsl(215 20% 14%)')};
}
.preview-error {
background: ${cssManager.bdTheme('hsl(0 72% 98%)', 'hsl(0 62% 20%)')};
color: ${cssManager.bdTheme('hsl(0 72% 40%)', 'hsl(0 70% 68%)')};
}
.preview-spinner {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 28%)')};
border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.preview-text {
font-size: 13px;
font-weight: 500;
}
.preview-error dees-icon {
font-size: 32px;
}
.preview-page-indicator {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
padding: 5px 8px;
background: ${cssManager.bdTheme('hsl(0 0% 0% / 0.7)', 'hsl(0 0% 100% / 0.9)')};
color: ${cssManager.bdTheme('white', 'hsl(215 20% 12%)')};
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-align: center;
backdrop-filter: blur(12px);
z-index: 15;
pointer-events: none;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive sizes */
:host([size="small"]) .preview-container {
width: 150px;
height: 195px;
}
:host([size="large"]) .preview-container {
width: 250px;
height: 325px;
}
/* Grid optimizations */
:host([grid-mode]) .preview-container {
will-change: auto;
}
:host([grid-mode]) .preview-canvas {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
`,
];

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,36 @@
import { domtools } from '@design.estate/dees-element';
export class PdfManager {
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();
// IMPORTANT: Disabled caching to ensure component isolation
// Each viewer instance gets its own document to prevent state sharing
// This fixes issues where multiple viewers interfere with each other
const loadingTask = this.pdfjsLib.getDocument(url);
const document = await loadingTask.promise;
return document;
}
public static releaseDocument(_url: string) {
// No-op since we're not caching documents anymore
// Each viewer manages its own document lifecycle
}
// Cache methods removed to ensure component isolation
// Each viewer now manages its own document lifecycle
}

View File

@@ -0,0 +1,3 @@
export * from './CanvasPool.js';
export * from './PdfManager.js';
export * from './utils.js';

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 = [];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
import { html } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 40px;
background: #f5f5f5;
}
.demo-section {
margin-bottom: 40px;
}
h3 {
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
dees-pdf-viewer {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.viewer-tall {
height: 800px;
}
.viewer-compact {
height: 500px;
}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Full Featured PDF Viewer with Toolbar</h3>
<dees-pdf-viewer
class="viewer-tall"
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
showToolbar="true"
showSidebar="false"
initialZoom="page-fit"
></dees-pdf-viewer>
</div>
<div class="demo-section">
<h3>PDF Viewer with Sidebar Navigation</h3>
<dees-pdf-viewer
class="viewer-tall"
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
showToolbar="true"
showSidebar="true"
initialZoom="page-width"
></dees-pdf-viewer>
</div>
<div class="demo-section">
<h3>Compact Viewer without Controls</h3>
<dees-pdf-viewer
class="viewer-compact"
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"
showToolbar="false"
showSidebar="false"
initialZoom="auto"
></dees-pdf-viewer>
</div>
</div>
`;

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,291 @@
import { css, cssManager } from '@design.estate/dees-element';
export const viewerStyles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 600px;
position: relative;
font-family: 'Geist Sans', sans-serif;
contain: layout style;
}
.pdf-viewer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(215 20% 10%)')};
position: relative;
overflow: hidden;
}
.toolbar {
height: 48px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 15%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 22%)')};
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
flex-shrink: 0;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.toolbar-group--end {
margin-left: auto;
}
.toolbar-button {
width: 32px;
height: 32px;
border-radius: 6px;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
}
.toolbar-button:hover:not(:disabled) {
background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 25% 22%)')};
}
.toolbar-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toolbar-button dees-icon {
font-size: 16px;
}
.page-info {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
}
.page-input {
width: 48px;
height: 28px;
border-radius: 4px;
border: 1px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 28%)')};
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')};
color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')};
text-align: center;
font-size: 14px;
font-family: inherit;
outline: none;
}
.page-input:focus {
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
}
.page-separator {
color: ${cssManager.bdTheme('hsl(215 16% 60%)', 'hsl(215 16% 50%)')};
}
.zoom-level {
font-size: 13px;
font-weight: 500;
min-width: 48px;
text-align: center;
}
.viewer-container {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
min-height: 0;
}
.sidebar {
width: 200px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 15%)')};
border-right: 1px solid ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 22%)')};
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.sidebar-header {
height: 40px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 22%)')};
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
}
.sidebar-close {
width: 24px;
height: 24px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
transition: background 0.15s ease;
}
.sidebar-close:hover {
background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 25% 22%)')};
}
.sidebar-close dees-icon {
font-size: 14px;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: block;
overscroll-behavior: contain;
min-height: 0;
}
.thumbnail {
position: relative;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.15s ease;
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(215 20% 18%)')};
display: block;
width: 100%;
margin-bottom: 12px;
/* Default A4 aspect ratio (297mm / 210mm ≈ 1.414) */
min-height: calc(176px * 1.414);
}
.thumbnail:last-child {
margin-bottom: 0;
}
.thumbnail:hover {
border-color: ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 35%)')};
}
.thumbnail.active {
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
}
.thumbnail-canvas {
display: block;
width: 100%;
height: auto;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
.thumbnail-number {
position: absolute;
bottom: 4px;
right: 4px;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.7)', 'rgba(0, 0, 0, 0.8)')};
color: white;
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
}
.viewer-main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
scroll-behavior: smooth;
overscroll-behavior: contain;
min-height: 0;
position: relative;
}
.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%)')};
}
.loading-spinner {
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 28%)')};
border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 14px;
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)')};
border-radius: 4px;
overflow: hidden;
display: inline-block;
}
.page-canvas {
display: block;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
.pdf-viewer.with-sidebar .viewer-main {
margin-left: 0;
}
`,
];

View File

@@ -0,0 +1,161 @@
import { DeesElement, property, html, customElement, domtools, type TemplateResult, type CSSResult, } from '@design.estate/dees-element';
import { Deferred } from '@push.rocks/smartpromise';
import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
import '../../00group-utility/dees-icon/dees-icon.js';
// import type pdfjsTypes from 'pdfjs-dist';
declare global {
interface HTMLElementTagNameMap {
'dees-pdf': DeesPdf;
}
}
/**
* @deprecated Use DeesPdfViewer or DeesTilePdf instead
* - DeesPdfViewer: Full-featured PDF viewing with controls, navigation, zoom
* - DeesTilePdf: Lightweight, performance-optimized tile preview for grids
*/
@customElement('dees-pdf')
export class DeesPdf extends DeesElement {
// DEMO
public static demo = () => html` <dees-pdf></dees-pdf> `;
public static demoGroups = ['Media', 'PDF'];
// INSTANCE
@property()
accessor pdfUrl: string =
'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf';
constructor() {
super();
// you have access to all kinds of things through this.
// this.setAttribute('gotIt','true');
}
public render(): TemplateResult {
return html`
<style>
:host {
font-family: 'Geist Sans', sans-serif;
display: block;
box-sizing: border-box;
max-width: 800px;
}
:host([hidden]) {
display: none;
}
#pdfcanvas {
box-shadow: 0px 0px 5px #ccc;
width: 100%;
cursor: pointer;
}
</style>
<canvas
id="pdfcanvas"
.height=${0}
.width=${0}
></canvas>
`;
}
public static pdfJsReady: Promise<any>;
public static pdfjsLib: any // typeof pdfjsTypes;
public async connectedCallback() {
super.connectedCallback();
if (!DeesPdf.pdfJsReady) {
const pdfJsReadyDeferred = domtools.plugins.smartpromise.defer();
DeesPdf.pdfJsReady = pdfJsReadyDeferred.promise;
// @ts-ignore
DeesPdf.pdfjsLib = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4.0.379/+esm');
DeesPdf.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.0.379/build/pdf.worker.mjs';
pdfJsReadyDeferred.resolve();
}
await DeesPdf.pdfJsReady;
this.displayContent();
}
public async displayContent() {
await DeesPdf.pdfJsReady;
// Asynchronous download of PDF
const loadingTask = DeesPdf.pdfjsLib.getDocument(this.pdfUrl);
loadingTask.promise.then(
(pdf) => {
console.log('PDF loaded');
// Fetch the first page
const pageNumber = 1;
pdf.getPage(pageNumber).then((page) => {
console.log('Page loaded');
const scale = 10;
const viewport = page.getViewport({ scale: scale });
// Prepare canvas using PDF page dimensions
const canvas: any = this.shadowRoot.querySelector('#pdfcanvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
// Render PDF page into canvas context
const renderContext = {
canvasContext: context,
viewport: viewport,
};
const renderTask = page.render(renderContext);
renderTask.promise.then(function () {
console.log('Page rendered');
});
});
},
(reason) => {
// PDF loading error
console.error(reason);
}
);
}
/**
* Provide context menu items for the global context menu handler
*/
public getContextMenuItems() {
return [
{
name: 'Open PDF in New Tab',
iconName: 'lucide:ExternalLink',
action: async () => {
window.open(this.pdfUrl, '_blank');
}
},
{ divider: true },
{
name: 'Copy PDF URL',
iconName: 'lucide:Copy',
action: async () => {
await navigator.clipboard.writeText(this.pdfUrl);
}
},
{
name: 'Download PDF',
iconName: 'lucide:Download',
action: async () => {
const link = document.createElement('a');
link.href = this.pdfUrl;
link.download = this.pdfUrl.split('/').pop() || 'document.pdf';
link.click();
}
}
];
}
}

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,122 @@
import { html, cssManager } from '@design.estate/dees-element';
export const demoFunc = () => html`
<style>
.demo-container {
padding: 48px;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
display: flex;
flex-direction: column;
gap: 40px;
}
.section {
max-width: 900px;
width: 100%;
margin: 0 auto;
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.section-description {
font-size: 14px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-bottom: 16px;
}
.preview-image {
height: 400px;
}
.preview-pdf {
height: 600px;
}
</style>
<div class="demo-container">
<div class="section">
<div class="section-title">Image Preview (URL)</div>
<div class="section-description">Auto-detects image from URL extension and renders with the image viewer.</div>
<dees-preview
class="preview-image"
url="https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1200"
filename="landscape.jpg"
></dees-preview>
</div>
<div class="section">
<div class="section-title">PDF Preview (URL)</div>
<div class="section-description">Auto-detects PDF and displays with the PDF viewer including toolbar.</div>
<dees-preview
class="preview-pdf"
url="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
filename="research-paper.pdf"
></dees-preview>
</div>
<div class="section">
<div class="section-title">Code Preview (Text Content)</div>
<div class="section-description">TypeScript code displayed with syntax highlighting via the codebox.</div>
<dees-preview
filename="example.ts"
language="typescript"
.textContent=${`import { html, css } from 'lit';
export class MyComponent extends LitElement {
static styles = css\`
:host {
display: block;
padding: 16px;
}
\`;
render() {
return html\`<h1>Hello World</h1>\`;
}
}`}
></dees-preview>
</div>
<div class="section">
<div class="section-title">Audio Preview (URL)</div>
<div class="section-description">Audio file detected by extension, shown with waveform player.</div>
<dees-preview
url="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
filename="song.mp3"
></dees-preview>
</div>
<div class="section">
<div class="section-title">Video Preview (URL)</div>
<div class="section-description">Video file detected from URL, rendered with custom video controls.</div>
<dees-preview
url="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
filename="big-buck-bunny.mp4"
></dees-preview>
</div>
<div class="section">
<div class="section-title">Explicit Type Override</div>
<div class="section-description">Force content type to 'text' even though the URL has no extension.</div>
<dees-preview
contentType="text"
.textContent=${'This is plain text content.\nIt preserves whitespace and line breaks.\n\nUseful for log files, READMEs, etc.'}
filename="notes.txt"
></dees-preview>
</div>
<div class="section">
<div class="section-title">Unknown Type</div>
<div class="section-description">When content type cannot be detected, shows a placeholder.</div>
<dees-preview
filename="data.bin"
contentType="unknown"
></dees-preview>
</div>
</div>
`;

View File

@@ -0,0 +1,507 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
property,
state,
cssManager,
} from '@design.estate/dees-element';
import '../dees-image-viewer/component.js';
import '../dees-audio-viewer/component.js';
import '../dees-video-viewer/component.js';
import '../../00group-dataview/dees-dataview-codebox/dees-dataview-codebox.js';
import '../dees-pdf-viewer/component.js';
import '../../00group-utility/dees-icon/dees-icon.js';
import { demoFunc } from './dees-preview.demo.js';
export type TPreviewContentType = 'image' | 'pdf' | 'audio' | 'video' | 'code' | 'text' | 'unknown';
declare global {
interface HTMLElementTagNameMap {
'dees-preview': DeesPreview;
}
}
const EXTENSION_MAP: Record<string, TPreviewContentType> = {
// Image
jpg: 'image', jpeg: 'image', png: 'image', gif: 'image', webp: 'image',
svg: 'image', bmp: 'image', avif: 'image', ico: 'image',
// PDF
pdf: 'pdf',
// Audio
mp3: 'audio', wav: 'audio', ogg: 'audio', flac: 'audio', aac: 'audio',
m4a: 'audio', opus: 'audio', weba: 'audio',
// Video
mp4: 'video', webm: 'video', mov: 'video', avi: 'video', mkv: 'video', ogv: 'video',
// Code
ts: 'code', js: 'code', jsx: 'code', tsx: 'code', json: 'code',
html: 'code', css: 'code', scss: 'code', less: 'code',
py: 'code', java: 'code', go: 'code', rs: 'code',
yaml: 'code', yml: 'code', xml: 'code', sql: 'code',
sh: 'code', bash: 'code', zsh: 'code', md: 'code',
c: 'code', cpp: 'code', h: 'code', hpp: 'code',
rb: 'code', php: 'code', swift: 'code', kt: 'code',
// Text
txt: 'text', log: 'text', csv: 'text', env: 'text',
};
const MIME_PREFIX_MAP: Record<string, TPreviewContentType> = {
'image/': 'image',
'audio/': 'audio',
'video/': 'video',
'application/pdf': 'pdf',
};
const EXTENSION_LANG_MAP: Record<string, string> = {
ts: 'typescript', tsx: 'typescript',
js: 'javascript', jsx: 'javascript',
json: 'json', html: 'xml', xml: 'xml',
css: 'css', scss: 'scss', less: 'less',
py: 'python', java: 'java', go: 'go', rs: 'rust',
yaml: 'yaml', yml: 'yaml', sql: 'sql',
sh: 'bash', bash: 'bash', zsh: 'bash',
c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
rb: 'ruby', php: 'php', swift: 'swift', kt: 'kotlin',
md: 'markdown',
};
const TYPE_ICONS: Record<TPreviewContentType, string> = {
image: 'lucide:Image',
pdf: 'lucide:FileText',
audio: 'lucide:Music',
video: 'lucide:Video',
code: 'lucide:Code',
text: 'lucide:FileText',
unknown: 'lucide:File',
};
@customElement('dees-preview')
export class DeesPreview extends DeesElement {
public static demo = demoFunc;
public static demoGroups = ['Media', 'Data View'];
// Content sources (use one)
@property()
accessor url: string = '';
@property({ attribute: false })
accessor file: File | undefined = undefined;
@property()
accessor base64: string = '';
@property()
accessor textContent: string = '';
// Hints & overrides
@property()
accessor contentType: TPreviewContentType | undefined = undefined;
@property()
accessor language: string = '';
@property()
accessor mimeType: string = '';
@property()
accessor filename: string = '';
// UI
@property({ type: Boolean })
accessor showToolbar: boolean = true;
@property({ type: Boolean })
accessor showFilename: boolean = true;
// Internal
@state()
accessor resolvedType: TPreviewContentType = 'unknown';
@state()
accessor resolvedSrc: string = '';
@state()
accessor resolvedText: string = '';
@state()
accessor resolvedLang: string = 'text';
@state()
accessor loading: boolean = false;
@state()
accessor error: string = '';
private objectUrl: string = '';
public render(): TemplateResult {
const displayName = this.filename || this.file?.name || this.getFilenameFromUrl() || '';
return html`
<style>
:host {
display: block;
position: relative;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.preview-container {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 8px;
overflow: hidden;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.header-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
height: 40px;
background: ${cssManager.bdTheme('#f9fafb', 'hsl(215 20% 15%)')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', 'hsl(217 25% 22%)')};
flex-shrink: 0;
}
.header-icon {
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
flex-shrink: 0;
font-size: 16px;
}
.header-filename {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.header-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.15)')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
text-transform: uppercase;
flex-shrink: 0;
}
.content-area {
flex: 1;
overflow: hidden;
position: relative;
min-height: 200px;
}
.content-area > * {
width: 100%;
height: 100%;
}
dees-image-viewer {
display: block;
height: 100%;
}
dees-pdf-viewer {
display: block;
height: 100%;
}
dees-video-viewer {
display: block;
}
dees-audio-viewer {
display: block;
height: 100%;
}
dees-dataview-codebox {
display: block;
}
.text-viewer {
margin: 0;
padding: 16px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
white-space: pre-wrap;
word-wrap: break-word;
overflow: auto;
height: 100%;
box-sizing: border-box;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 100%;
min-height: 200px;
color: ${cssManager.bdTheme('#a1a1aa', '#71717a')};
}
.placeholder dees-icon {
opacity: 0.5;
font-size: 32px;
}
.placeholder-text {
font-size: 14px;
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
height: 100%;
min-height: 200px;
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.error-container dees-icon {
font-size: 32px;
}
.error-text {
font-size: 13px;
}
</style>
<div class="preview-container">
${this.showFilename && displayName ? html`
<div class="header-bar">
<dees-icon class="header-icon" icon="${TYPE_ICONS[this.resolvedType]}"></dees-icon>
<span class="header-filename">${displayName}</span>
<span class="header-badge">${this.resolvedType}</span>
</div>
` : ''}
<div class="content-area">
${this.error ? html`
<div class="error-container">
<dees-icon icon="lucide:AlertTriangle"></dees-icon>
<span class="error-text">${this.error}</span>
</div>
` : this.loading ? html`
<div class="loading-container">
<div class="loading-spinner"></div>
</div>
` : this.renderContent()}
</div>
</div>
`;
}
private renderContent(): TemplateResult {
switch (this.resolvedType) {
case 'image':
return html`
<dees-image-viewer
.src=${this.resolvedSrc}
.showToolbar=${this.showToolbar}
alt="${this.filename || ''}"
></dees-image-viewer>
`;
case 'pdf':
return html`
<dees-pdf-viewer
.pdfUrl=${this.resolvedSrc}
.showToolbar=${this.showToolbar}
initialZoom="page-fit"
></dees-pdf-viewer>
`;
case 'audio':
return html`
<dees-audio-viewer
.src=${this.resolvedSrc}
.title=${this.filename || this.file?.name || ''}
></dees-audio-viewer>
`;
case 'video':
return html`
<dees-video-viewer
.src=${this.resolvedSrc}
></dees-video-viewer>
`;
case 'code':
return html`
<dees-dataview-codebox
.progLang=${this.resolvedLang}
.codeToDisplay=${this.resolvedText}
></dees-dataview-codebox>
`;
case 'text':
return html`<pre class="text-viewer">${this.resolvedText}</pre>`;
default:
return html`
<div class="placeholder">
<dees-icon icon="lucide:FileQuestion"></dees-icon>
<span class="placeholder-text">Preview not available</span>
</div>
`;
}
}
public async updated(changedProperties: Map<PropertyKey, unknown>): Promise<void> {
super.updated(changedProperties);
const relevant = ['url', 'file', 'base64', 'textContent', 'contentType', 'language', 'mimeType', 'filename'];
const needsResolve = relevant.some((key) => changedProperties.has(key));
if (needsResolve) {
await this.resolveContent();
}
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
this.revokeObjectUrl();
}
private async resolveContent(): Promise<void> {
this.error = '';
this.revokeObjectUrl();
// Detect type
this.resolvedType = this.detectType();
// Resolve source
try {
if (this.url) {
this.resolvedSrc = this.url;
if (this.resolvedType === 'code' || this.resolvedType === 'text') {
if (!this.textContent) {
this.loading = true;
const response = await fetch(this.url);
this.resolvedText = await response.text();
this.loading = false;
} else {
this.resolvedText = this.textContent;
}
}
} else if (this.file) {
this.objectUrl = URL.createObjectURL(this.file);
this.resolvedSrc = this.objectUrl;
if (this.resolvedType === 'code' || this.resolvedType === 'text') {
this.loading = true;
this.resolvedText = await this.file.text();
this.loading = false;
}
} else if (this.base64) {
const mime = this.mimeType || 'application/octet-stream';
this.resolvedSrc = `data:${mime};base64,${this.base64}`;
} else if (this.textContent) {
this.resolvedText = this.textContent;
}
} catch {
this.error = 'Failed to load content';
this.loading = false;
}
// Resolve language for code
this.resolvedLang = this.resolveLanguage();
}
private detectType(): TPreviewContentType {
// 1. Explicit override
if (this.contentType) return this.contentType;
// 2. MIME type
const mime = this.mimeType || this.file?.type || '';
if (mime) {
if (mime === 'application/pdf') return 'pdf';
for (const [prefix, type] of Object.entries(MIME_PREFIX_MAP)) {
if (mime.startsWith(prefix)) return type;
}
if (mime.startsWith('text/')) return 'text';
}
// 3. File extension
const ext = this.getExtension();
if (ext && EXTENSION_MAP[ext]) return EXTENSION_MAP[ext];
// 4. If textContent is provided, assume code or text
if (this.textContent) {
return this.language ? 'code' : 'text';
}
return 'unknown';
}
private getExtension(): string {
const name = this.filename || this.file?.name || '';
if (name) {
const parts = name.split('.');
if (parts.length > 1) return parts.pop()!.toLowerCase();
}
if (this.url) {
try {
const pathname = new URL(this.url, 'https://placeholder.com').pathname;
const parts = pathname.split('.');
if (parts.length > 1) return parts.pop()!.toLowerCase();
} catch {
// Invalid URL
}
}
return '';
}
private getFilenameFromUrl(): string {
if (!this.url) return '';
try {
const pathname = new URL(this.url, 'https://placeholder.com').pathname;
return pathname.split('/').pop() || '';
} catch {
return '';
}
}
private resolveLanguage(): string {
if (this.language) return this.language;
const ext = this.getExtension();
return EXTENSION_LANG_MAP[ext] || 'text';
}
private revokeObjectUrl(): void {
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl);
this.objectUrl = '';
}
}
}

View File

@@ -0,0 +1 @@
export * from './dees-preview.js';

View File

@@ -0,0 +1,344 @@
import {
property,
state,
html,
customElement,
css,
cssManager,
type TemplateResult,
type CSSResult,
} from '@design.estate/dees-element';
import { DeesTileBase } from '../dees-tile-shared/DeesTileBase.js';
import { tileBaseStyles } from '../dees-tile-shared/styles.js';
import { demo } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-tile-audio': DeesTileAudio;
}
}
@customElement('dees-tile-audio')
export class DeesTileAudio extends DeesTileBase {
public static demo = demo;
public static demoGroups = ['Media'];
public static styles = [
...tileBaseStyles,
css`
.audio-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: ${cssManager.bdTheme(
'linear-gradient(135deg, hsl(250 40% 96%), hsl(280 30% 94%))',
'linear-gradient(135deg, hsl(250 30% 16%), hsl(280 25% 14%))'
)};
}
.music-icon {
font-size: 48px;
color: ${cssManager.bdTheme('hsl(250 60% 65%)', 'hsl(250 60% 70%)')};
opacity: 0.8;
}
.audio-title {
font-size: 12px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(250 20% 35%)', 'hsl(250 20% 80%)')};
text-align: center;
padding: 0 16px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audio-artist {
font-size: 11px;
font-weight: 400;
color: ${cssManager.bdTheme('hsl(250 15% 50%)', 'hsl(250 15% 65%)')};
text-align: center;
padding: 0 16px;
margin-top: -12px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.waveform-container {
width: calc(100% - 32px);
height: 40px;
position: relative;
overflow: hidden;
}
.waveform-container canvas {
width: 100%;
height: 100%;
display: block;
}
.duration-badge {
position: absolute;
bottom: 8px;
right: 8px;
padding: 3px 8px;
background: ${cssManager.bdTheme('hsl(0 0% 0% / 0.6)', 'hsl(0 0% 100% / 0.85)')};
color: ${cssManager.bdTheme('white', 'hsl(215 20% 12%)')};
border-radius: 4px;
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
backdrop-filter: blur(8px);
z-index: 10;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 18;
pointer-events: none;
}
.tile-container.clickable:hover .play-overlay {
opacity: 1;
}
.play-circle {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
}
.play-circle dees-icon {
font-size: 20px;
color: white;
}
`,
] as any;
@property({ type: String })
accessor src: string = '';
@property({ type: String })
accessor title: string = '';
@property({ type: String })
accessor artist: string = '';
@state()
accessor duration: number = 0;
@state()
accessor waveformData: number[] = [];
@state()
accessor waveformReady: boolean = false;
@state()
accessor isPreviewPlaying: boolean = false;
private audioElement: HTMLAudioElement | null = null;
private previewTimeout: ReturnType<typeof setTimeout> | null = null;
private hasLoadedWaveform: boolean = false;
protected renderTileContent(): TemplateResult {
return html`
<div class="audio-content">
<dees-icon class="music-icon" icon="lucide:Music"></dees-icon>
${this.title ? html`<div class="audio-title">${this.title}</div>` : ''}
${this.artist ? html`<div class="audio-artist">${this.artist}</div>` : ''}
${this.waveformReady ? html`
<div class="waveform-container">
<canvas></canvas>
</div>
` : ''}
</div>
${this.duration > 0 ? html`
<div class="duration-badge">${this.formatTime(this.duration)}</div>
` : ''}
<div class="play-overlay">
<div class="play-circle">
<dees-icon icon="lucide:Play"></dees-icon>
</div>
</div>
${this.clickable ? html`
<div class="tile-overlay">
<dees-icon icon="lucide:Headphones"></dees-icon>
<span>Play Audio</span>
</div>
` : ''}
`;
}
protected getTileClickDetail(): Record<string, unknown> {
return {
src: this.src,
title: this.title,
artist: this.artist,
duration: this.duration,
};
}
protected onBecameVisible(): void {
if (!this.hasLoadedWaveform && this.src) {
this.hasLoadedWaveform = true;
this.loadAudioMeta();
}
}
private async loadAudioMeta(): Promise<void> {
this.loading = true;
try {
// Load duration via Audio element
const audio = new Audio();
audio.crossOrigin = 'anonymous';
audio.preload = 'metadata';
await new Promise<void>((resolve, reject) => {
audio.addEventListener('loadedmetadata', () => {
this.duration = audio.duration;
resolve();
}, { once: true });
audio.addEventListener('error', () => reject(new Error('Failed to load audio')), { once: true });
audio.src = this.src;
});
// Load waveform data
await this.loadWaveform();
this.loading = false;
} catch {
this.loading = false;
// Don't set error - audio may still be playable, just no waveform
}
}
private async loadWaveform(): Promise<void> {
try {
const response = await fetch(this.src);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const channelData = audioBuffer.getChannelData(0);
const bars = 80;
const blockSize = Math.floor(channelData.length / bars);
const waveform: number[] = [];
for (let i = 0; i < bars; i++) {
let sum = 0;
for (let j = 0; j < blockSize; j++) {
sum += Math.abs(channelData[i * blockSize + j]);
}
waveform.push(sum / blockSize);
}
const max = Math.max(...waveform);
this.waveformData = waveform.map((v) => (max > 0 ? v / max : 0));
this.waveformReady = true;
await audioContext.close();
await this.updateComplete;
this.drawWaveform();
} catch {
this.waveformReady = false;
}
}
private drawWaveform(): void {
if (!this.waveformReady) return;
const canvas = this.shadowRoot?.querySelector('.waveform-container canvas') as HTMLCanvasElement;
if (!canvas) return;
const container = canvas.parentElement!;
const dpr = window.devicePixelRatio || 1;
const width = container.clientWidth;
const height = container.clientHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height);
const bars = this.waveformData.length;
if (bars === 0) return;
const barWidth = width / bars;
const isDark = document.body.classList.contains('theme-dark') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
const barColor = isDark ? 'hsl(250 50% 60%)' : 'hsl(250 50% 70%)';
ctx.fillStyle = barColor;
for (let i = 0; i < bars; i++) {
const amplitude = this.waveformData[i];
const barHeight = Math.max(2, amplitude * (height - 4));
const x = i * barWidth;
const y = (height - barHeight) / 2;
ctx.fillRect(x + 0.5, y, barWidth - 1, barHeight);
}
}
public async updated(changedProperties: Map<PropertyKey, unknown>): Promise<void> {
super.updated(changedProperties);
if (changedProperties.has('src') && this.src && this.isVisible) {
this.hasLoadedWaveform = true;
this.waveformReady = false;
this.duration = 0;
this.loadAudioMeta();
}
if (changedProperties.has('waveformReady') && this.waveformReady) {
await this.updateComplete;
this.drawWaveform();
}
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
if (this.previewTimeout) {
clearTimeout(this.previewTimeout);
}
if (this.audioElement) {
this.audioElement.pause();
this.audioElement.src = '';
this.audioElement = null;
}
}
private formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
}

View File

@@ -0,0 +1,77 @@
import { html } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 40px;
background: #f5f5f5;
}
.demo-section {
margin-bottom: 60px;
}
h3 {
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.tile-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: flex-start;
}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Audio Tiles</h3>
<div class="tile-row">
<dees-tile-audio
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
title="SoundHelix Song 1"
artist="T. Schuerger"
label="soundhelix-1.mp3"
@tile-click=${(e: CustomEvent) => console.log('Audio clicked:', e.detail)}
></dees-tile-audio>
<dees-tile-audio
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3"
title="SoundHelix Song 2"
artist="T. Schuerger"
label="soundhelix-2.mp3"
></dees-tile-audio>
<dees-tile-audio
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3"
title="SoundHelix Song 3"
label="soundhelix-3.mp3"
></dees-tile-audio>
</div>
</div>
<div class="demo-section">
<h3>Size Variants</h3>
<div class="tile-row">
<dees-tile-audio
size="small"
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
title="Small"
label="small.mp3"
></dees-tile-audio>
<dees-tile-audio
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
title="Default"
label="default.mp3"
></dees-tile-audio>
<dees-tile-audio
size="large"
src="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
title="Large"
label="large.mp3"
></dees-tile-audio>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,181 @@
import {
property,
html,
customElement,
css,
cssManager,
type TemplateResult,
type CSSResult,
} from '@design.estate/dees-element';
import { DeesTileBase } from '../dees-tile-shared/DeesTileBase.js';
import { tileBaseStyles } from '../dees-tile-shared/styles.js';
import { demo } from './demo.js';
export interface ITileFolderItem {
type: 'pdf' | 'image' | 'audio' | 'video' | 'note' | 'folder' | 'unknown';
thumbnailSrc?: string;
name: string;
}
const TYPE_ICON_MAP: Record<string, string> = {
pdf: 'lucide:FileText',
image: 'lucide:Image',
audio: 'lucide:Music',
video: 'lucide:Video',
note: 'lucide:FileCode',
folder: 'lucide:Folder',
unknown: 'lucide:File',
};
declare global {
interface HTMLElementTagNameMap {
'dees-tile-folder': DeesTileFolder;
}
}
@customElement('dees-tile-folder')
export class DeesTileFolder extends DeesTileBase {
public static demo = demo;
public static demoGroups = ['Media'];
public static styles = [
...tileBaseStyles,
css`
.folder-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: ${cssManager.bdTheme('hsl(40 30% 97%)', 'hsl(215 20% 14%)')};
overflow: hidden;
}
.folder-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px 8px;
flex-shrink: 0;
}
.folder-icon {
font-size: 18px;
color: ${cssManager.bdTheme('hsl(40 80% 50%)', 'hsl(40 70% 60%)')};
}
.folder-name {
font-size: 12px;
font-weight: 700;
color: ${cssManager.bdTheme('hsl(215 20% 20%)', 'hsl(215 16% 80%)')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.preview-grid {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 4px;
padding: 0 14px 14px;
min-height: 0;
}
.grid-cell {
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
overflow: hidden;
background: ${cssManager.bdTheme('hsl(215 20% 94%)', 'hsl(215 20% 18%)')};
position: relative;
}
.grid-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.grid-cell dees-icon {
font-size: 20px;
color: ${cssManager.bdTheme('hsl(215 16% 60%)', 'hsl(215 16% 55%)')};
}
.grid-cell-empty {
background: ${cssManager.bdTheme('hsl(215 15% 96%)', 'hsl(215 20% 16%)')};
}
.item-count-badge {
position: absolute;
bottom: 8px;
right: 8px;
padding: 3px 8px;
background: ${cssManager.bdTheme('hsl(0 0% 0% / 0.6)', 'hsl(0 0% 100% / 0.85)')};
color: ${cssManager.bdTheme('white', 'hsl(215 20% 12%)')};
border-radius: 4px;
font-size: 10px;
font-weight: 600;
backdrop-filter: blur(8px);
z-index: 10;
}
`,
] as any;
@property({ type: String })
accessor name: string = '';
@property({ attribute: false })
accessor items: ITileFolderItem[] = [];
protected renderTileContent(): TemplateResult {
const previewItems = this.items.slice(0, 4);
const emptyCells = 4 - previewItems.length;
return html`
<div class="folder-content">
<div class="folder-header">
<dees-icon class="folder-icon" icon="lucide:Folder"></dees-icon>
<div class="folder-name">${this.name || 'Untitled Folder'}</div>
</div>
<div class="preview-grid">
${previewItems.map((item) => html`
<div class="grid-cell">
${item.thumbnailSrc ? html`
<img src="${item.thumbnailSrc}" alt="${item.name}" />
` : html`
<dees-icon icon="${TYPE_ICON_MAP[item.type] || TYPE_ICON_MAP.unknown}"></dees-icon>
`}
</div>
`)}
${Array.from({ length: emptyCells }).map(() => html`
<div class="grid-cell grid-cell-empty"></div>
`)}
</div>
</div>
<div class="item-count-badge">
${this.items.length} item${this.items.length !== 1 ? 's' : ''}
</div>
${this.clickable ? html`
<div class="tile-overlay">
<dees-icon icon="lucide:FolderOpen"></dees-icon>
<span>Open Folder</span>
</div>
` : ''}
`;
}
protected getTileClickDetail(): Record<string, unknown> {
return {
name: this.name,
itemCount: this.items.length,
items: this.items,
};
}
}

View File

@@ -0,0 +1,122 @@
import { html } from '@design.estate/dees-element';
import type { ITileFolderItem } from './component.js';
export const demo = () => {
const photosFolder: ITileFolderItem[] = [
{ type: 'image', name: 'sunset.jpg', thumbnailSrc: 'https://picsum.photos/200/200?random=1' },
{ type: 'image', name: 'mountain.jpg', thumbnailSrc: 'https://picsum.photos/200/200?random=2' },
{ type: 'image', name: 'ocean.jpg', thumbnailSrc: 'https://picsum.photos/200/200?random=3' },
{ type: 'image', name: 'forest.jpg', thumbnailSrc: 'https://picsum.photos/200/200?random=4' },
{ type: 'image', name: 'city.jpg', thumbnailSrc: 'https://picsum.photos/200/200?random=5' },
{ type: 'image', name: 'desert.jpg', thumbnailSrc: 'https://picsum.photos/200/200?random=6' },
];
const projectFolder: ITileFolderItem[] = [
{ type: 'note', name: 'README.md' },
{ type: 'note', name: 'package.json' },
{ type: 'folder', name: 'src' },
{ type: 'folder', name: 'test' },
{ type: 'note', name: 'tsconfig.json' },
{ type: 'pdf', name: 'docs.pdf' },
{ type: 'image', name: 'logo.png', thumbnailSrc: 'https://picsum.photos/100/100?random=10' },
];
const mediaFolder: ITileFolderItem[] = [
{ type: 'video', name: 'intro.mp4' },
{ type: 'audio', name: 'background.mp3' },
{ type: 'image', name: 'thumbnail.jpg', thumbnailSrc: 'https://picsum.photos/200/200?random=20' },
{ type: 'pdf', name: 'storyboard.pdf' },
];
const emptyFolder: ITileFolderItem[] = [];
const singleItemFolder: ITileFolderItem[] = [
{ type: 'pdf', name: 'report.pdf' },
];
return html`
<style>
.demo-container {
padding: 40px;
background: #f5f5f5;
}
.demo-section {
margin-bottom: 60px;
}
h3 {
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.tile-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: flex-start;
}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Folder Tiles</h3>
<div class="tile-row">
<dees-tile-folder
name="Photos"
.items=${photosFolder}
label="6 photos"
@tile-click=${(e: CustomEvent) => console.log('Folder clicked:', e.detail)}
></dees-tile-folder>
<dees-tile-folder
name="my-project"
.items=${projectFolder}
label="Project files"
></dees-tile-folder>
<dees-tile-folder
name="Media Assets"
.items=${mediaFolder}
label="Mixed media"
></dees-tile-folder>
</div>
</div>
<div class="demo-section">
<h3>Edge Cases</h3>
<div class="tile-row">
<dees-tile-folder
name="Empty Folder"
.items=${emptyFolder}
></dees-tile-folder>
<dees-tile-folder
name="Single Item"
.items=${singleItemFolder}
></dees-tile-folder>
</div>
</div>
<div class="demo-section">
<h3>Size Variants</h3>
<div class="tile-row">
<dees-tile-folder
size="small"
name="Small"
.items=${photosFolder}
></dees-tile-folder>
<dees-tile-folder
name="Default"
.items=${photosFolder}
></dees-tile-folder>
<dees-tile-folder
size="large"
name="Large"
.items=${photosFolder}
></dees-tile-folder>
</div>
</div>
</div>
`;
};

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,172 @@
import {
property,
state,
html,
customElement,
css,
cssManager,
type TemplateResult,
type CSSResult,
} from '@design.estate/dees-element';
import { DeesTileBase } from '../dees-tile-shared/DeesTileBase.js';
import { tileBaseStyles } from '../dees-tile-shared/styles.js';
import { demo } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-tile-image': DeesTileImage;
}
}
@customElement('dees-tile-image')
export class DeesTileImage extends DeesTileBase {
public static demo = demo;
public static demoGroups = ['Media'];
public static styles = [
...tileBaseStyles,
css`
.image-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: ${cssManager.bdTheme(
'repeating-conic-gradient(#e8e8e8 0% 25%, white 0% 50%) 50% / 16px 16px',
'repeating-conic-gradient(hsl(215 20% 18%) 0% 25%, hsl(215 20% 14%) 0% 50%) 50% / 16px 16px'
)};
}
.image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: opacity 0.3s ease;
}
.image-wrapper img.loaded {
opacity: 1;
}
.image-wrapper img.loading {
opacity: 0;
}
.dimension-badge {
position: absolute;
top: 8px;
right: 8px;
padding: 3px 8px;
background: ${cssManager.bdTheme('hsl(0 0% 0% / 0.6)', 'hsl(0 0% 100% / 0.85)')};
color: ${cssManager.bdTheme('white', 'hsl(215 20% 12%)')};
border-radius: 4px;
font-size: 10px;
font-weight: 600;
backdrop-filter: blur(8px);
z-index: 15;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.tile-container.clickable:hover .dimension-badge {
opacity: 1;
}
`,
] as any;
@property({ type: String })
accessor src: string = '';
@property({ type: String })
accessor alt: string = '';
@state()
accessor imageLoaded: boolean = false;
@state()
accessor imageWidth: number = 0;
@state()
accessor imageHeight: number = 0;
private hasStartedLoading: boolean = false;
protected renderTileContent(): TemplateResult {
return html`
<div class="image-wrapper">
${this.hasStartedLoading ? html`
<img
class="${this.imageLoaded ? 'loaded' : 'loading'}"
src="${this.src}"
alt="${this.alt}"
@load=${this.handleImageLoad}
@error=${this.handleImageError}
/>
` : ''}
</div>
${this.imageWidth > 0 && this.imageHeight > 0 ? html`
<div class="dimension-badge">
${this.imageWidth} × ${this.imageHeight}
</div>
` : ''}
${this.imageLoaded ? html`
<div class="tile-info">
<dees-icon icon="lucide:Image"></dees-icon>
<span class="tile-info-text">${this.imageWidth} × ${this.imageHeight}</span>
</div>
` : ''}
${this.clickable ? html`
<div class="tile-overlay">
<dees-icon icon="lucide:Eye"></dees-icon>
<span>View Image</span>
</div>
` : ''}
`;
}
protected getTileClickDetail(): Record<string, unknown> {
return {
src: this.src,
alt: this.alt,
width: this.imageWidth,
height: this.imageHeight,
};
}
protected onBecameVisible(): void {
if (!this.hasStartedLoading && this.src) {
this.hasStartedLoading = true;
this.loading = true;
this.requestUpdate();
}
}
private handleImageLoad(e: Event): void {
const img = e.target as HTMLImageElement;
this.imageWidth = img.naturalWidth;
this.imageHeight = img.naturalHeight;
this.imageLoaded = true;
this.loading = false;
}
private handleImageError(): void {
this.error = true;
this.loading = false;
}
public async updated(changedProperties: Map<PropertyKey, unknown>): Promise<void> {
super.updated(changedProperties);
if (changedProperties.has('src') && this.src && this.isVisible) {
this.hasStartedLoading = true;
this.imageLoaded = false;
this.loading = true;
}
}
}

View File

@@ -0,0 +1,84 @@
import { html } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 40px;
background: #f5f5f5;
}
.demo-section {
margin-bottom: 60px;
}
h3 {
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.tile-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: flex-start;
}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Image Tiles</h3>
<div class="tile-row">
<dees-tile-image
src="https://picsum.photos/800/600"
alt="Landscape photo"
label="landscape.jpg"
@tile-click=${(e: CustomEvent) => console.log('Image clicked:', e.detail)}
></dees-tile-image>
<dees-tile-image
src="https://picsum.photos/400/400"
alt="Square photo"
label="square.png"
></dees-tile-image>
<dees-tile-image
src="https://picsum.photos/300/900"
alt="Portrait photo"
label="portrait.webp"
></dees-tile-image>
</div>
</div>
<div class="demo-section">
<h3>Size Variants</h3>
<div class="tile-row">
<dees-tile-image
size="small"
src="https://picsum.photos/200/200"
alt="Small"
label="small.jpg"
></dees-tile-image>
<dees-tile-image
src="https://picsum.photos/600/400"
alt="Default"
label="default.jpg"
></dees-tile-image>
<dees-tile-image
size="large"
src="https://picsum.photos/1200/800"
alt="Large"
label="large.jpg"
></dees-tile-image>
</div>
</div>
<div class="demo-section">
<h3>Error State (broken URL)</h3>
<dees-tile-image
src="https://invalid-url-that-does-not-exist.example/image.png"
alt="Broken"
label="broken.png"
></dees-tile-image>
</div>
</div>
`;

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,231 @@
import {
property,
state,
html,
customElement,
css,
cssManager,
type TemplateResult,
type CSSResult,
} from '@design.estate/dees-element';
import { DeesTileBase } from '../dees-tile-shared/DeesTileBase.js';
import { tileBaseStyles } from '../dees-tile-shared/styles.js';
import { demo } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-tile-note': DeesTileNote;
}
}
@customElement('dees-tile-note')
export class DeesTileNote extends DeesTileBase {
public static demo = demo;
public static demoGroups = ['Media'];
public static styles = [
...tileBaseStyles,
css`
.note-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: ${cssManager.bdTheme('#ffffff', 'hsl(60 5% 96%)')};
overflow: hidden;
}
.note-header {
padding: 12px 14px 8px;
flex-shrink: 0;
}
.note-title {
font-size: 12px;
font-weight: 700;
color: ${cssManager.bdTheme('hsl(215 20% 20%)', 'hsl(215 20% 20%)')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.note-body {
flex: 1;
padding: 0 14px 14px;
position: relative;
overflow: hidden;
}
.note-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
line-height: 1.5;
color: ${cssManager.bdTheme('hsl(215 10% 40%)', 'hsl(215 10% 35%)')};
white-space: pre-wrap;
word-wrap: break-word;
overflow: hidden;
margin: 0;
}
.note-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: linear-gradient(
transparent,
${cssManager.bdTheme('#ffffff', 'hsl(60 5% 96%)')}
);
pointer-events: none;
}
.note-language {
position: absolute;
top: 8px;
right: 8px;
padding: 2px 6px;
background: ${cssManager.bdTheme('hsl(215 20% 92%)', 'hsl(215 20% 88%)')};
color: ${cssManager.bdTheme('hsl(215 16% 50%)', 'hsl(215 16% 40%)')};
border-radius: 3px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
z-index: 5;
}
.note-lines {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 34px;
border-right: 1px solid ${cssManager.bdTheme('hsl(0 70% 85%)', 'hsl(0 50% 80%)')};
display: flex;
flex-direction: column;
padding-top: 12px;
}
.line-number {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 9px;
line-height: 15px; /* matches 10px * 1.5 line-height */
color: ${cssManager.bdTheme('hsl(215 10% 75%)', 'hsl(215 10% 70%)')};
text-align: right;
padding-right: 6px;
}
.note-line-indicator {
position: absolute;
bottom: 8px;
right: 8px;
padding: 3px 8px;
background: rgba(0, 0, 0, 0.6);
color: white;
border-radius: 4px;
font-size: 9px;
font-weight: 600;
font-variant-numeric: tabular-nums;
backdrop-filter: blur(8px);
z-index: 10;
pointer-events: none;
}
`,
] as any;
@property({ type: String })
accessor title: string = '';
@property({ type: String })
accessor content: string = '';
@property({ type: String })
accessor language: string = '';
@state()
accessor isHovering: boolean = false;
private noteBodyElement: HTMLElement | null = null;
protected renderTileContent(): TemplateResult {
const lines = this.content.split('\n');
return html`
<div class="note-content">
${this.language ? html`
<div class="note-language">${this.language}</div>
` : ''}
${this.title ? html`
<div class="note-header">
<div class="note-title">${this.title}</div>
</div>
` : ''}
<div class="note-body">
<pre class="note-text">${lines.join('\n')}</pre>
${!this.isHovering ? html`<div class="note-fade"></div>` : ''}
</div>
${this.isHovering && lines.length > 12 ? html`
<div class="note-line-indicator">
Line ${this.getVisibleLineRange(lines.length)}
</div>
` : ''}
</div>
${this.clickable ? html`
<div class="tile-overlay">
<dees-icon icon="lucide:FileText"></dees-icon>
<span>Open Note</span>
</div>
` : ''}
`;
}
protected getTileClickDetail(): Record<string, unknown> {
return {
title: this.title,
content: this.content,
language: this.language,
};
}
protected onTileMouseEnter(): void {
this.isHovering = true;
if (!this.noteBodyElement) {
this.noteBodyElement = this.shadowRoot?.querySelector('.note-body') as HTMLElement;
}
}
protected onTileMouseLeave(): void {
this.isHovering = false;
if (this.noteBodyElement) {
this.noteBodyElement.scrollTop = 0;
}
}
protected onTileMouseMove(e: MouseEvent): void {
if (!this.isHovering || !this.noteBodyElement) return;
const totalLines = this.content.split('\n').length;
if (totalLines <= 12) return;
const rect = this.getBoundingClientRect();
const y = e.clientY - rect.top;
const percentage = Math.max(0, Math.min(1, y / rect.height));
const maxScroll = this.noteBodyElement.scrollHeight - this.noteBodyElement.clientHeight;
this.noteBodyElement.scrollTop = percentage * maxScroll;
}
private getVisibleLineRange(totalLines: number): string {
if (!this.noteBodyElement) return `112 of ${totalLines}`;
const lineHeight = 15; // 10px font × 1.5 line-height
const firstLine = Math.floor(this.noteBodyElement.scrollTop / lineHeight) + 1;
const visibleCount = Math.floor(this.noteBodyElement.clientHeight / lineHeight);
const lastLine = Math.min(firstLine + visibleCount - 1, totalLines);
return `${firstLine}${lastLine} of ${totalLines}`;
}
}

View File

@@ -0,0 +1,135 @@
import { html } from '@design.estate/dees-element';
export const demo = () => {
const sampleCode = `import { html } from 'lit';
export class MyComponent {
private items: string[] = [];
render() {
return html\`
<div class="container">
\${this.items.map(item => html\`
<span>\${item}</span>
\`)}
</div>
\`;
}
}`;
const sampleText = `Meeting Notes - Q4 Planning
Date: January 15, 2026
Attendees: Alice, Bob, Charlie
Key Decisions:
1. Launch new feature by March
2. Hire 2 more engineers
3. Migrate to new CI/CD pipeline
4. Update design system to v3
Action Items:
- Alice: Draft PRD by next week
- Bob: Set up interview pipeline
- Charlie: Evaluate Jenkins vs GitHub Actions`;
const sampleJson = `{
"name": "@design.estate/dees-catalog",
"version": "3.38.0",
"description": "Design component catalog",
"dependencies": {
"@design.estate/dees-element": "^2.0.0",
"lit": "^3.1.0"
},
"scripts": {
"build": "tsbuild",
"test": "tstest"
}
}`;
return html`
<style>
.demo-container {
padding: 40px;
background: #f5f5f5;
}
.demo-section {
margin-bottom: 60px;
}
h3 {
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.tile-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: flex-start;
}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Note Tiles</h3>
<div class="tile-row">
<dees-tile-note
title="component.ts"
.content=${sampleCode}
language="typescript"
label="component.ts"
@tile-click=${(e: CustomEvent) => console.log('Note clicked:', e.detail)}
></dees-tile-note>
<dees-tile-note
title="Meeting Notes"
.content=${sampleText}
label="meeting-notes.txt"
></dees-tile-note>
<dees-tile-note
title="package.json"
.content=${sampleJson}
language="json"
label="package.json"
></dees-tile-note>
</div>
</div>
<div class="demo-section">
<h3>Size Variants</h3>
<div class="tile-row">
<dees-tile-note
size="small"
title="small.ts"
.content=${sampleCode}
language="ts"
label="small.ts"
></dees-tile-note>
<dees-tile-note
title="default.ts"
.content=${sampleCode}
language="ts"
label="default.ts"
></dees-tile-note>
<dees-tile-note
size="large"
title="large.ts"
.content=${sampleCode}
language="ts"
label="large.ts"
></dees-tile-note>
</div>
</div>
<div class="demo-section">
<h3>Without Title</h3>
<dees-tile-note
.content=${sampleText}
label="untitled.txt"
></dees-tile-note>
</div>
</div>
`;
};

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,421 @@
import { property, html, customElement, type TemplateResult, type CSSResult } from '@design.estate/dees-element';
import { DeesTileBase } from '../dees-tile-shared/DeesTileBase.js';
import { tileBaseStyles } from '../dees-tile-shared/styles.js';
import { PdfManager } from '../dees-pdf-shared/PdfManager.js';
import { CanvasPool, type PooledCanvas } from '../dees-pdf-shared/CanvasPool.js';
import { PerformanceMonitor, throttle } from '../dees-pdf-shared/utils.js';
import { tilePdfStyles } from './styles.js';
import { demo as demoFunc } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-tile-pdf': DeesTilePdf;
}
}
@customElement('dees-tile-pdf')
export class DeesTilePdf extends DeesTileBase {
public static demo = demoFunc;
public static demoGroups = ['Media', 'PDF'];
public static styles = [...tileBaseStyles, tilePdfStyles] as any;
@property({ type: String })
accessor pdfUrl: string = '';
@property({ type: Number })
accessor currentPreviewPage: number = 1;
@property({ type: Number })
accessor pageCount: number = 0;
@property({ type: Boolean })
accessor rendered: boolean = false;
@property({ type: Boolean })
accessor isHovering: boolean = false;
@property({ type: Boolean })
accessor isA4Format: boolean = true;
private renderPagesTask: Promise<void> | null = null;
private renderPagesQueued: boolean = false;
private pdfDocument: any;
private canvases: PooledCanvas[] = [];
private resizeObserver?: ResizeObserver;
private stackElement: HTMLElement | null = null;
private loadedPdfUrl: string | null = null;
protected renderTileContent(): TemplateResult {
return html`
<div class="preview-stack ${!this.isA4Format ? 'non-a4' : ''}">
<canvas
class="preview-canvas"
data-page="${this.currentPreviewPage}"
></canvas>
</div>
${this.pageCount > 1 && this.isHovering ? html`
<div class="preview-page-indicator">
Page ${this.currentPreviewPage} of ${this.pageCount}
</div>
` : ''}
${this.pageCount > 0 && !this.isHovering ? html`
<div class="tile-info">
<dees-icon icon="lucide:FileText"></dees-icon>
<span class="tile-info-text">${this.pageCount} page${this.pageCount > 1 ? 's' : ''}</span>
</div>
` : ''}
${this.clickable ? html`
<div class="tile-overlay">
<dees-icon icon="lucide:Eye"></dees-icon>
<span>View PDF</span>
</div>
` : ''}
`;
}
protected getTileClickDetail(): Record<string, unknown> {
return {
pdfUrl: this.pdfUrl,
pageCount: this.pageCount,
};
}
protected onBecameVisible(): void {
if (!this.rendered && this.pdfUrl) {
this.loadAndRenderPreview();
}
}
protected onTileMouseEnter(): void {
this.isHovering = true;
}
protected onTileMouseLeave(): void {
this.isHovering = false;
if (this.currentPreviewPage !== 1) {
this.currentPreviewPage = 1;
void this.scheduleRenderPages();
}
}
protected onTileMouseMove(e: MouseEvent): void {
if (!this.isHovering || this.pageCount <= 1) return;
const rect = this.getBoundingClientRect();
const x = e.clientX - rect.left;
const width = rect.width;
const percentage = Math.max(0, Math.min(1, x / width));
const newPage = Math.ceil(percentage * this.pageCount) || 1;
if (newPage !== this.currentPreviewPage) {
this.currentPreviewPage = newPage;
void this.scheduleRenderPages();
}
}
public async connectedCallback(): Promise<void> {
await super.connectedCallback();
await this.updateComplete;
this.cacheElements();
this.setupResizeObserver();
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
this.cleanup();
this.resizeObserver?.disconnect();
this.resizeObserver = undefined;
}
private async loadAndRenderPreview(): Promise<void> {
if (this.rendered || this.loading) return;
this.loading = true;
this.error = false;
PerformanceMonitor.mark(`preview-load-${this.pdfUrl}`);
try {
this.pdfDocument = await PdfManager.loadDocument(this.pdfUrl);
this.pageCount = this.pdfDocument.numPages;
this.currentPreviewPage = 1;
this.loadedPdfUrl = this.pdfUrl;
this.loading = false;
await this.updateComplete;
this.cacheElements();
await this.scheduleRenderPages();
this.rendered = true;
const duration = PerformanceMonitor.measure(`preview-render-${this.pdfUrl}`, `preview-load-${this.pdfUrl}`);
console.log(`PDF tile rendered in ${duration}ms`);
} catch (error) {
console.error('Failed to load PDF tile:', error);
this.error = true;
this.loading = false;
}
}
private scheduleRenderPages(): Promise<void> {
if (!this.pdfDocument) {
return Promise.resolve();
}
if (this.renderPagesTask) {
this.renderPagesQueued = true;
return this.renderPagesTask;
}
this.renderPagesTask = (async () => {
try {
await this.performRenderPages();
} catch (error) {
console.error('Failed to render PDF tile pages:', error);
}
})().finally(() => {
this.renderPagesTask = null;
if (this.renderPagesQueued) {
this.renderPagesQueued = false;
void this.scheduleRenderPages();
}
});
return this.renderPagesTask;
}
private async performRenderPages(): Promise<void> {
if (!this.pdfDocument) return;
await new Promise(resolve => requestAnimationFrame(resolve));
const canvas = this.shadowRoot?.querySelector('.preview-canvas') as HTMLCanvasElement;
if (!canvas) return;
this.clearCanvases();
this.cacheElements();
const { availableWidth, availableHeight } = this.getAvailableSize();
try {
const pageNum = this.currentPreviewPage;
const page = await this.pdfDocument.getPage(pageNum);
const initialViewport = page.getViewport({ scale: 1 });
const aspectRatio = initialViewport.height / initialViewport.width;
const a4PortraitRatio = 1.414;
const a4LandscapeRatio = 0.707;
const letterPortraitRatio = 1.294;
const letterLandscapeRatio = 0.773;
const tolerance = 0.05;
const isA4Portrait = Math.abs(aspectRatio - a4PortraitRatio) < (a4PortraitRatio * tolerance);
const isA4Landscape = Math.abs(aspectRatio - a4LandscapeRatio) < (a4LandscapeRatio * tolerance);
const isLetterPortrait = Math.abs(aspectRatio - letterPortraitRatio) < (letterPortraitRatio * tolerance);
const isLetterLandscape = Math.abs(aspectRatio - letterLandscapeRatio) < (letterLandscapeRatio * tolerance);
this.isA4Format = isA4Portrait || isA4Landscape || isLetterPortrait || isLetterLandscape;
const adjustedWidth = this.isA4Format ? availableWidth : availableWidth - 24;
const adjustedHeight = this.isA4Format ? availableHeight : availableHeight - 24;
const scaleX = adjustedWidth > 0 ? adjustedWidth / initialViewport.width : 0;
const scaleY = adjustedHeight > 0 ? adjustedHeight / initialViewport.height : 0;
const baseScale = Math.min(scaleX || 0.5, scaleY || scaleX || 0.5);
const renderScale = Math.min(baseScale * 2, 3.0);
if (!Number.isFinite(renderScale) || renderScale <= 0) {
page.cleanup?.();
return;
}
const viewport = page.getViewport({ scale: renderScale });
const pooledCanvas = CanvasPool.acquire(viewport.width, viewport.height);
this.canvases.push(pooledCanvas);
const renderContext = {
canvasContext: pooledCanvas.ctx,
viewport: viewport,
};
await page.render(renderContext).promise;
canvas.width = viewport.width;
canvas.height = viewport.height;
const displayWidth = adjustedWidth;
const displayHeight = (viewport.height / viewport.width) * adjustedWidth;
if (displayHeight > adjustedHeight) {
const altDisplayHeight = adjustedHeight;
const altDisplayWidth = (viewport.width / viewport.height) * adjustedHeight;
canvas.style.width = `${altDisplayWidth}px`;
canvas.style.height = `${altDisplayHeight}px`;
} else {
canvas.style.width = `${displayWidth}px`;
canvas.style.height = `${displayHeight}px`;
}
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(pooledCanvas.canvas, 0, 0);
}
page.cleanup();
} catch (error) {
console.error(`Failed to render page ${this.currentPreviewPage}:`, error);
}
}
private clearCanvases(): void {
for (const pooledCanvas of this.canvases) {
CanvasPool.release(pooledCanvas);
}
this.canvases = [];
}
private cleanup(): void {
this.clearCanvases();
if (this.pdfDocument) {
PdfManager.releaseDocument(this.loadedPdfUrl ?? this.pdfUrl);
this.pdfDocument = null;
}
this.renderPagesQueued = false;
this.pageCount = 0;
this.currentPreviewPage = 1;
this.isHovering = false;
this.isA4Format = true;
this.stackElement = null;
this.loadedPdfUrl = null;
this.rendered = false;
this.loading = false;
this.error = false;
}
public async updated(changedProperties: Map<PropertyKey, unknown>): Promise<void> {
super.updated(changedProperties);
if (changedProperties.has('pdfUrl') && this.pdfUrl) {
const previousUrl = changedProperties.get('pdfUrl') as string | undefined;
if (previousUrl) {
PdfManager.releaseDocument(previousUrl);
}
this.cleanup();
this.rendered = false;
this.currentPreviewPage = 1;
const rect = this.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
this.loadAndRenderPreview();
}
}
if (changedProperties.has('currentPreviewPage') && this.rendered) {
await this.scheduleRenderPages();
}
}
public getContextMenuItems(): any[] {
const items: any[] = [];
if (this.clickable) {
items.push({
name: 'View PDF',
iconName: 'lucide:Eye',
action: async () => {
this.dispatchEvent(new CustomEvent('tile-click', {
detail: this.getTileClickDetail(),
bubbles: true,
composed: true,
}));
}
});
items.push({ divider: true });
}
items.push(
{
name: 'Open PDF in New Tab',
iconName: 'lucide:ExternalLink',
action: async () => {
window.open(this.pdfUrl, '_blank');
}
},
{ divider: true },
{
name: 'Copy PDF URL',
iconName: 'lucide:Copy',
action: async () => {
await navigator.clipboard.writeText(this.pdfUrl);
}
},
{
name: 'Download PDF',
iconName: 'lucide:Download',
action: async () => {
const link = document.createElement('a');
link.href = this.pdfUrl;
link.download = this.pdfUrl.split('/').pop() || 'document.pdf';
link.click();
}
}
);
if (this.pageCount > 0) {
items.push(
{ divider: true },
{
name: `${this.pageCount} page${this.pageCount > 1 ? 's' : ''}`,
iconName: 'lucide:FileText',
disabled: true,
action: async () => {}
}
);
}
return items;
}
private cacheElements(): void {
if (!this.stackElement) {
this.stackElement = this.shadowRoot?.querySelector('.preview-stack') as HTMLElement;
}
}
private setupResizeObserver(): void {
if (this.resizeObserver) return;
this.resizeObserver = new ResizeObserver(() => {
if (this.rendered && this.pdfDocument && !this.loading) {
void this.scheduleRenderPages();
}
});
this.resizeObserver.observe(this);
}
private getAvailableSize(): { availableWidth: number; availableHeight: number } {
if (!this.stackElement) {
this.stackElement = this.shadowRoot?.querySelector('.preview-stack') as HTMLElement;
}
if (!this.stackElement) {
return { availableWidth: 200, availableHeight: 260 };
}
const rect = this.stackElement.getBoundingClientRect();
const availableWidth = Math.max(rect.width, 0) || 200;
const availableHeight = Math.max(rect.height, 0) || 260;
return { availableWidth, availableHeight };
}
}

View File

@@ -0,0 +1,128 @@
import { html } from '@design.estate/dees-element';
export const demo = () => {
const samplePdfs = [
'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf',
'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf',
];
const generateGridItems = (count: number) => {
const items = [];
for (let i = 0; i < count; i++) {
const pdfUrl = samplePdfs[i % samplePdfs.length];
items.push(html`
<dees-tile-pdf
pdfUrl="${pdfUrl}"
clickable="true"
grid-mode
@tile-click=${(e: CustomEvent) => {
console.log('PDF Tile clicked:', e.detail);
alert(`PDF clicked: ${e.detail.pageCount} pages`);
}}
></dees-tile-pdf>
`);
}
return items;
};
return html`
<style>
.demo-container {
padding: 40px;
background: #f5f5f5;
}
.demo-section {
margin-bottom: 60px;
}
h3 {
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 24px;
}
.preview-row {
display: flex;
gap: 24px;
align-items: center;
margin-bottom: 20px;
}
.preview-label {
font-size: 14px;
font-weight: 500;
min-width: 100px;
}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Single PDF Tile</h3>
<dees-tile-pdf
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
clickable="true"
></dees-tile-pdf>
</div>
<div class="demo-section">
<h3>Different Sizes</h3>
<div class="preview-row">
<div class="preview-label">Small:</div>
<dees-tile-pdf
size="small"
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"
clickable="true"
></dees-tile-pdf>
</div>
<div class="preview-row">
<div class="preview-label">Default:</div>
<dees-tile-pdf
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"
clickable="true"
></dees-tile-pdf>
</div>
<div class="preview-row">
<div class="preview-label">Large:</div>
<dees-tile-pdf
size="large"
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"
clickable="true"
></dees-tile-pdf>
</div>
</div>
<div class="demo-section">
<h3>With Label</h3>
<dees-tile-pdf
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
clickable="true"
label="Research Paper.pdf"
></dees-tile-pdf>
</div>
<div class="demo-section">
<h3>Non-Clickable</h3>
<dees-tile-pdf
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"
clickable="false"
></dees-tile-pdf>
</div>
<div class="demo-section">
<h3>Grid - 20 PDFs with Lazy Loading</h3>
<div class="preview-grid">
${generateGridItems(20)}
</div>
</div>
</div>
`;
};

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,61 @@
import { css, cssManager } from '@design.estate/dees-element';
export const tilePdfStyles = css`
.preview-stack {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
overflow: hidden;
}
.preview-stack.non-a4 {
padding: 12px;
}
.preview-canvas {
position: relative;
background: white;
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
image-rendering: auto;
-webkit-font-smoothing: antialiased;
box-shadow: 0 1px 3px ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(0, 0, 0, 0.3)')};
}
.non-a4 .preview-canvas {
border: 1px solid ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 25% 24%)')};
border-radius: 4px;
}
.preview-page-indicator {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
padding: 5px 8px;
background: ${cssManager.bdTheme('hsl(0 0% 0% / 0.7)', 'hsl(0 0% 100% / 0.9)')};
color: ${cssManager.bdTheme('white', 'hsl(215 20% 12%)')};
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-align: center;
backdrop-filter: blur(12px);
z-index: 15;
pointer-events: none;
animation: fadeIn 0.2s ease;
}
/* Grid optimizations */
:host([grid-mode]) .preview-canvas {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
`;

View File

@@ -0,0 +1,130 @@
import {
DeesElement,
html,
property,
type TemplateResult,
type CSSResult,
} from '@design.estate/dees-element';
import { tileBaseStyles } from './styles.js';
import '../../00group-utility/dees-icon/dees-icon.js';
export abstract class DeesTileBase extends DeesElement {
public static styles: CSSResult[] = tileBaseStyles as any;
@property({ type: Boolean })
accessor clickable: boolean = true;
@property({ type: Boolean })
accessor loading: boolean = false;
@property({ type: Boolean })
accessor error: boolean = false;
@property({ type: String, reflect: true })
accessor size: 'small' | 'default' | 'large' = 'default';
@property({ type: String })
accessor label: string = '';
private observer: IntersectionObserver | undefined;
private _visible: boolean = false;
/** Whether this tile is currently visible in the viewport */
protected get isVisible(): boolean {
return this._visible;
}
public render(): TemplateResult {
return html`
<div
class="tile-container ${this.clickable ? 'clickable' : ''} ${this.loading ? 'loading' : ''} ${this.error ? 'error' : ''}"
@click=${this.handleTileClick}
@mouseenter=${this.onTileMouseEnter}
@mouseleave=${this.onTileMouseLeave}
@mousemove=${this.onTileMouseMove}
>
${this.loading ? html`
<div class="tile-loading">
<div class="tile-spinner"></div>
<div class="tile-loading-text">Loading...</div>
</div>
` : ''}
${this.error ? html`
<div class="tile-error">
<dees-icon icon="lucide:AlertTriangle"></dees-icon>
<div class="tile-error-text">Failed to load</div>
</div>
` : ''}
${!this.loading && !this.error ? this.renderTileContent() : ''}
${this.label ? html`
<div class="tile-label">${this.label}</div>
` : ''}
</div>
`;
}
/** Subclasses implement this to render their specific content */
protected abstract renderTileContent(): TemplateResult;
public async connectedCallback(): Promise<void> {
await super.connectedCallback();
this.setupIntersectionObserver();
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
if (this.observer) {
this.observer.disconnect();
this.observer = undefined;
}
}
private setupIntersectionObserver(): void {
this.observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const wasVisible = this._visible;
this._visible = entry.isIntersecting;
if (this._visible && !wasVisible) {
this.onBecameVisible();
}
}
},
{ root: null, rootMargin: '200px', threshold: 0.01 }
);
this.observer.observe(this);
}
/** Called when the tile first enters the viewport. Override for lazy loading. */
protected onBecameVisible(): void {
// Subclasses can override
}
/** Called when mouse enters the tile container. Override in subclasses. */
protected onTileMouseEnter(): void {}
/** Called when mouse leaves the tile container. Override in subclasses. */
protected onTileMouseLeave(): void {}
/** Called when mouse moves over the tile container. Override in subclasses. */
protected onTileMouseMove(_e: MouseEvent): void {}
protected handleTileClick(): void {
if (!this.clickable) return;
this.dispatchEvent(
new CustomEvent('tile-click', {
detail: this.getTileClickDetail(),
bubbles: true,
composed: true,
})
);
}
/** Return the detail object for tile-click events. Override in subclasses. */
protected getTileClickDetail(): Record<string, unknown> {
return {};
}
}

View File

@@ -0,0 +1,2 @@
export { DeesTileBase } from './DeesTileBase.js';
export { tileBaseStyles } from './styles.js';

View File

@@ -0,0 +1,213 @@
import { css, cssManager } from '@design.estate/dees-element';
export const tileBaseStyles = [
cssManager.defaultStyles,
css`
:host {
display: inline-block;
position: relative;
}
.tile-container {
position: relative;
width: 200px;
height: 260px;
background: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(215 20% 14%)')};
border-radius: 4px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 1px 3px ${cssManager.bdTheme('rgba(0, 0, 0, 0.12)', 'rgba(0, 0, 0, 0.24)')};
}
.tile-container.clickable {
cursor: pointer;
}
.tile-container.clickable:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px ${cssManager.bdTheme('rgba(0, 0, 0, 0.12)', 'rgba(0, 0, 0, 0.3)')};
}
.tile-container.clickable:hover .tile-overlay {
opacity: 1;
}
.tile-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
overflow: hidden;
}
.tile-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.7)', 'rgba(0, 0, 0, 0.8)')};
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 20;
}
.tile-overlay dees-icon {
font-size: 24px;
color: white;
}
.tile-overlay span {
font-size: 14px;
font-weight: 500;
color: white;
}
.tile-info {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
padding: 6px 10px;
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.92)', 'hsl(215 20% 12% / 0.92)')};
border-radius: 6px;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
backdrop-filter: blur(12px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.tile-info dees-icon {
font-size: 13px;
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
}
.tile-info-text {
font-weight: 500;
font-size: 11px;
}
.tile-badge {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
padding: 5px 8px;
background: ${cssManager.bdTheme('hsl(0 0% 0% / 0.7)', 'hsl(0 0% 100% / 0.9)')};
color: ${cssManager.bdTheme('white', 'hsl(215 20% 12%)')};
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-align: center;
backdrop-filter: blur(12px);
z-index: 15;
pointer-events: none;
animation: fadeIn 0.2s ease;
}
.tile-loading,
.tile-error {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
}
.tile-loading {
background: ${cssManager.bdTheme('hsl(0 0% 99%)', 'hsl(215 20% 14%)')};
}
.tile-error {
background: ${cssManager.bdTheme('hsl(0 72% 98%)', 'hsl(0 62% 20%)')};
color: ${cssManager.bdTheme('hsl(0 72% 40%)', 'hsl(0 70% 68%)')};
}
.tile-error dees-icon {
font-size: 32px;
}
.tile-spinner {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 28%)')};
border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
animation: spin 0.8s linear infinite;
}
.tile-loading-text,
.tile-error-text {
font-size: 13px;
font-weight: 500;
}
.tile-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 6px 10px;
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.95)', 'hsl(215 20% 12% / 0.95)')};
font-size: 11px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215 16% 35%)', 'hsl(215 16% 75%)')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 10;
backdrop-filter: blur(12px);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Size variants */
:host([size="small"]) .tile-container {
width: 150px;
height: 195px;
}
:host([size="large"]) .tile-container {
width: 250px;
height: 325px;
}
/* Grid optimizations */
:host([grid-mode]) .tile-container {
will-change: auto;
}
`,
];

View File

@@ -0,0 +1,317 @@
import {
property,
state,
html,
customElement,
css,
cssManager,
type TemplateResult,
type CSSResult,
} from '@design.estate/dees-element';
import { DeesTileBase } from '../dees-tile-shared/DeesTileBase.js';
import { tileBaseStyles } from '../dees-tile-shared/styles.js';
import { demo } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-tile-video': DeesTileVideo;
}
}
@customElement('dees-tile-video')
export class DeesTileVideo extends DeesTileBase {
public static demo = demo;
public static demoGroups = ['Media'];
public static styles = [
...tileBaseStyles,
css`
.video-wrapper {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
.video-wrapper video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.video-wrapper canvas {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.duration-badge {
position: absolute;
bottom: 8px;
right: 8px;
padding: 3px 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
backdrop-filter: blur(8px);
z-index: 10;
}
.play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 15;
pointer-events: none;
transition: opacity 0.2s ease;
}
.play-overlay dees-icon {
font-size: 20px;
color: white;
}
.tile-container.clickable:hover .play-overlay {
opacity: 0;
}
.video-hover-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 5;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-hover-preview.active {
opacity: 1;
}
`,
] as any;
@property({ type: String })
accessor src: string = '';
@property({ type: String })
accessor poster: string = '';
@state()
accessor duration: number = 0;
@state()
accessor thumbnailCaptured: boolean = false;
@state()
accessor isHovering: boolean = false;
private thumbnailCanvas: HTMLCanvasElement | null = null;
private hoverVideo: HTMLVideoElement | null = null;
private hasStartedLoading: boolean = false;
protected renderTileContent(): TemplateResult {
return html`
<div class="video-wrapper">
${this.poster ? html`
<img class="poster-image" src="${this.poster}" alt="" />
` : this.thumbnailCaptured ? html`
<canvas></canvas>
` : html`
<div style="width: 100%; height: 100%; background: #000;"></div>
`}
${this.isHovering && this.src ? html`
<video
class="video-hover-preview ${this.isHovering ? 'active' : ''}"
.src=${this.src}
muted
playsinline
@loadeddata=${this.handleHoverVideoLoaded}
></video>
` : ''}
</div>
${this.duration > 0 ? html`
<div class="duration-badge">${this.formatTime(this.duration)}</div>
` : ''}
${!this.isHovering ? html`
<div class="play-overlay">
<dees-icon icon="lucide:Play"></dees-icon>
</div>
` : ''}
${this.clickable ? html`
<div class="tile-overlay">
<dees-icon icon="lucide:Play"></dees-icon>
<span>Play Video</span>
</div>
` : ''}
`;
}
protected getTileClickDetail(): Record<string, unknown> {
return {
src: this.src,
poster: this.poster,
duration: this.duration,
};
}
protected onBecameVisible(): void {
if (!this.hasStartedLoading && this.src) {
this.hasStartedLoading = true;
this.captureFirstFrame();
}
}
private async captureFirstFrame(): Promise<void> {
if (this.poster) {
// If poster is provided, just load duration
this.loadDuration();
return;
}
this.loading = true;
try {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.muted = true;
video.preload = 'metadata';
await new Promise<void>((resolve, reject) => {
video.addEventListener('loadeddata', () => {
this.duration = video.duration;
// Capture the first frame
video.currentTime = 0.1; // Slightly after start for better frame
video.addEventListener('seeked', () => {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0);
this.thumbnailCanvas = canvas;
this.thumbnailCaptured = true;
}
// Clean up
video.src = '';
video.load();
resolve();
}, { once: true });
}, { once: true });
video.addEventListener('error', () => reject(new Error('Failed to load video')), { once: true });
video.src = this.src;
});
this.loading = false;
// Copy thumbnail to shadow DOM canvas
await this.updateComplete;
this.copyThumbnailToCanvas();
} catch {
this.loading = false;
// Don't set error for thumbnail failure
this.loadDuration();
}
}
private loadDuration(): void {
const video = document.createElement('video');
video.preload = 'metadata';
video.addEventListener('loadedmetadata', () => {
this.duration = video.duration;
video.src = '';
video.load();
});
video.src = this.src;
}
private copyThumbnailToCanvas(): void {
if (!this.thumbnailCanvas) return;
const canvas = this.shadowRoot?.querySelector('.video-wrapper canvas') as HTMLCanvasElement;
if (!canvas) return;
canvas.width = this.thumbnailCanvas.width;
canvas.height = this.thumbnailCanvas.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(this.thumbnailCanvas, 0, 0);
}
}
protected onTileMouseEnter(): void {
this.isHovering = true;
}
protected onTileMouseLeave(): void {
this.isHovering = false;
// The video element will be removed from DOM by the template
this.hoverVideo = null;
}
private handleHoverVideoLoaded(e: Event): void {
this.hoverVideo = e.target as HTMLVideoElement;
this.hoverVideo.play().catch(() => {
// Autoplay may be blocked
});
}
public async updated(changedProperties: Map<PropertyKey, unknown>): Promise<void> {
super.updated(changedProperties);
if (changedProperties.has('src') && this.src && this.isVisible) {
this.hasStartedLoading = true;
this.thumbnailCaptured = false;
this.duration = 0;
this.captureFirstFrame();
}
if (changedProperties.has('thumbnailCaptured') && this.thumbnailCaptured) {
await this.updateComplete;
this.copyThumbnailToCanvas();
}
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
if (this.hoverVideo) {
this.hoverVideo.pause();
this.hoverVideo.src = '';
this.hoverVideo = null;
}
this.thumbnailCanvas = null;
}
private formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
}

View File

@@ -0,0 +1,79 @@
import { html } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 40px;
background: #f5f5f5;
}
.demo-section {
margin-bottom: 60px;
}
h3 {
margin-bottom: 20px;
font-size: 18px;
font-weight: 600;
}
.tile-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: flex-start;
}
</style>
<div class="demo-container">
<div class="demo-section">
<h3>Video Tiles</h3>
<div class="tile-row">
<dees-tile-video
src="https://www.w3schools.com/html/mov_bbb.mp4"
label="bunny.mp4"
@tile-click=${(e: CustomEvent) => console.log('Video clicked:', e.detail)}
></dees-tile-video>
<dees-tile-video
src="https://www.w3schools.com/html/movie.mp4"
poster="https://picsum.photos/400/300"
label="movie.mp4"
></dees-tile-video>
<dees-tile-video
src="https://www.w3schools.com/html/mov_bbb.mp4"
label="another-video.mp4"
></dees-tile-video>
</div>
</div>
<div class="demo-section">
<h3>Size Variants</h3>
<div class="tile-row">
<dees-tile-video
size="small"
src="https://www.w3schools.com/html/mov_bbb.mp4"
label="small.mp4"
></dees-tile-video>
<dees-tile-video
src="https://www.w3schools.com/html/mov_bbb.mp4"
label="default.mp4"
></dees-tile-video>
<dees-tile-video
size="large"
src="https://www.w3schools.com/html/mov_bbb.mp4"
label="large.mp4"
></dees-tile-video>
</div>
</div>
<div class="demo-section">
<h3>With Poster Image</h3>
<dees-tile-video
src="https://www.w3schools.com/html/movie.mp4"
poster="https://picsum.photos/600/400"
label="poster-video.mp4"
></dees-tile-video>
</div>
</div>
`;

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,506 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
property,
state,
cssManager,
} from '@design.estate/dees-element';
import '../../00group-utility/dees-icon/dees-icon.js';
import { demo } from './demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-video-viewer': DeesVideoViewer;
}
}
@customElement('dees-video-viewer')
export class DeesVideoViewer extends DeesElement {
public static demo = demo;
public static demoGroups = ['Media'];
@property()
accessor src: string = '';
@property()
accessor poster: string = '';
@property({ type: Boolean })
accessor showControls: boolean = true;
@property({ type: Boolean })
accessor autoplay: boolean = false;
@property({ type: Boolean })
accessor loop: boolean = false;
@property({ type: Boolean })
accessor muted: boolean = false;
@state()
accessor isPlaying: boolean = false;
@state()
accessor currentTime: number = 0;
@state()
accessor duration: number = 0;
@state()
accessor volume: number = 1;
@state()
accessor loading: boolean = true;
@state()
accessor error: string = '';
@state()
accessor isFullscreen: boolean = false;
@state()
accessor controlsVisible: boolean = true;
private hideControlsTimer: ReturnType<typeof setTimeout> | null = null;
private videoElement: HTMLVideoElement | null = null;
public render(): TemplateResult {
return html`
<style>
:host {
display: block;
position: relative;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.video-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000000;
border-radius: 8px;
overflow: hidden;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
cursor: pointer;
}
.center-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s ease, transform 0.2s ease;
opacity: ${this.isPlaying ? 0 : 1};
pointer-events: ${this.isPlaying ? 'none' : 'auto'};
}
.center-play dees-icon {
font-size: 28px;
}
.center-play:hover {
background: rgba(0, 0, 0, 0.8);
transform: translate(-50%, -50%) scale(1.1);
}
.controls-bar {
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
padding: 24px 12px 12px;
display: flex;
flex-direction: column;
gap: 8px;
opacity: ${this.controlsVisible || !this.isPlaying ? 1 : 0};
transition: opacity 0.3s ease;
}
.seekbar-row {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
position: relative;
}
.seekbar-row:hover {
height: 6px;
}
.seekbar-progress {
height: 100%;
background: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
border-radius: 2px;
pointer-events: none;
}
.controls-row {
display: flex;
align-items: center;
gap: 8px;
}
.ctrl-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #ffffff;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease;
flex-shrink: 0;
}
.ctrl-button dees-icon {
font-size: 16px;
}
.ctrl-button:hover {
background: rgba(255, 255, 255, 0.15);
}
.time-display {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.8);
user-select: none;
flex-shrink: 0;
}
.spacer {
flex: 1;
}
.volume-group {
display: flex;
align-items: center;
gap: 4px;
}
.volume-slider {
width: 60px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffffff;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffffff;
border: none;
cursor: pointer;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.6);
}
.error-overlay dees-icon {
color: #f87171;
font-size: 32px;
}
.error-text {
font-size: 13px;
}
</style>
<div
class="video-container"
@mousemove=${this.handleMouseMove}
@mouseleave=${this.handleMouseLeave}
>
<video
.src=${this.src}
.poster=${this.poster}
.muted=${this.muted}
.loop=${this.loop}
?autoplay=${this.autoplay}
?controls=${!this.showControls}
playsinline
@loadedmetadata=${this.handleLoadedMetadata}
@play=${this.handlePlay}
@pause=${this.handlePause}
@ended=${this.handleEnded}
@timeupdate=${this.handleTimeUpdate}
@error=${this.handleError}
@waiting=${() => { this.loading = true; }}
@canplay=${() => { this.loading = false; }}
></video>
${this.showControls ? html`
<div class="overlay" @click=${this.handleOverlayClick}>
<div class="center-play">
<dees-icon icon="lucide:Play"></dees-icon>
</div>
<div class="controls-bar" @click=${(e: Event) => e.stopPropagation()}>
<div class="seekbar-row" @click=${this.handleSeek}>
<div class="seekbar-progress" style="width: ${this.duration ? (this.currentTime / this.duration) * 100 : 0}%"></div>
</div>
<div class="controls-row">
<button class="ctrl-button" @click=${this.togglePlay}>
<dees-icon icon="lucide:${this.isPlaying ? 'Pause' : 'Play'}"></dees-icon>
</button>
<span class="time-display">
${this.formatTime(this.currentTime)} / ${this.formatTime(this.duration)}
</span>
<span class="spacer"></span>
<div class="volume-group">
<button class="ctrl-button" @click=${this.toggleMute}>
<dees-icon icon="lucide:${this.muted || this.volume === 0 ? 'VolumeX' : this.volume < 0.5 ? 'Volume1' : 'Volume2'}"></dees-icon>
</button>
<input
class="volume-slider"
type="range"
min="0"
max="1"
step="0.01"
.value=${String(this.muted ? 0 : this.volume)}
@input=${this.handleVolumeChange}
/>
</div>
<button class="ctrl-button" @click=${this.toggleFullscreen} title="Fullscreen">
<dees-icon icon="lucide:${this.isFullscreen ? 'Minimize' : 'Maximize'}"></dees-icon>
</button>
</div>
</div>
</div>
` : ''}
${this.loading && !this.error ? html`
<div class="loading-overlay">
<div class="loading-spinner"></div>
</div>
` : ''}
${this.error ? html`
<div class="error-overlay">
<dees-icon icon="lucide:VideoOff"></dees-icon>
<span class="error-text">${this.error}</span>
</div>
` : ''}
</div>
`;
}
public async firstUpdated(): Promise<void> {
this.videoElement = this.shadowRoot?.querySelector('video') || null;
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
document.removeEventListener('fullscreenchange', this.handleFullscreenChange);
if (this.hideControlsTimer) {
clearTimeout(this.hideControlsTimer);
}
}
public play(): void {
this.videoElement?.play();
}
public pause(): void {
this.videoElement?.pause();
}
public togglePlay(): void {
if (this.isPlaying) {
this.pause();
} else {
this.play();
}
}
public seek(time: number): void {
if (this.videoElement) {
this.videoElement.currentTime = time;
}
}
public setVolume(v: number): void {
this.volume = Math.max(0, Math.min(1, v));
if (this.videoElement) {
this.videoElement.volume = this.volume;
}
}
public toggleFullscreen(): void {
const container = this.shadowRoot?.querySelector('.video-container') as HTMLElement;
if (!container) return;
if (this.isFullscreen) {
document.exitFullscreen?.();
} else {
container.requestFullscreen?.();
}
}
private handleLoadedMetadata(): void {
if (this.videoElement) {
this.duration = this.videoElement.duration;
this.loading = false;
}
}
private handlePlay(): void {
this.isPlaying = true;
this.scheduleHideControls();
}
private handlePause(): void {
this.isPlaying = false;
this.controlsVisible = true;
}
private handleEnded(): void {
this.isPlaying = false;
this.controlsVisible = true;
}
private handleTimeUpdate(): void {
if (this.videoElement) {
this.currentTime = this.videoElement.currentTime;
}
}
private handleError(): void {
this.error = 'Failed to load video';
this.loading = false;
}
private handleOverlayClick(): void {
this.togglePlay();
}
private handleSeek(e: MouseEvent): void {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
this.seek(ratio * this.duration);
}
private handleVolumeChange(e: Event): void {
const value = parseFloat((e.target as HTMLInputElement).value);
this.setVolume(value);
this.muted = value === 0;
}
private toggleMute(): void {
this.muted = !this.muted;
if (this.videoElement) {
this.videoElement.muted = this.muted;
}
}
private handleMouseMove(): void {
this.controlsVisible = true;
this.scheduleHideControls();
}
private handleMouseLeave(): void {
if (this.isPlaying) {
this.controlsVisible = false;
}
}
private scheduleHideControls(): void {
if (this.hideControlsTimer) {
clearTimeout(this.hideControlsTimer);
}
if (this.isPlaying) {
this.hideControlsTimer = setTimeout(() => {
this.controlsVisible = false;
}, 3000);
}
}
private handleFullscreenChange = (): void => {
this.isFullscreen = !!document.fullscreenElement;
};
private formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
}

View File

@@ -0,0 +1,63 @@
import { html, cssManager } from '@design.estate/dees-element';
export const demo = () => html`
<style>
.demo-container {
padding: 48px;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
display: flex;
flex-direction: column;
gap: 32px;
}
.section {
max-width: 800px;
width: 100%;
margin: 0 auto;
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.section-description {
font-size: 14px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-bottom: 16px;
}
</style>
<div class="demo-container">
<div class="section">
<div class="section-title">Video with Custom Controls</div>
<div class="section-description">A video player with overlay controls, seeking, and volume adjustment.</div>
<dees-video-viewer
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
poster="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg"
></dees-video-viewer>
</div>
<div class="section">
<div class="section-title">Autoplay Muted</div>
<div class="section-description">Video that autoplays muted, commonly used for previews.</div>
<dees-video-viewer
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
poster="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ElephantsDream.jpg"
.autoplay=${true}
.muted=${true}
></dees-video-viewer>
</div>
<div class="section">
<div class="section-title">Native Controls</div>
<div class="section-description">Video using browser-native controls instead of custom overlay.</div>
<dees-video-viewer
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4"
.showControls=${false}
></dees-video-viewer>
</div>
</div>
`;

View File

@@ -0,0 +1 @@
export * from './component.js';

View File

@@ -0,0 +1,20 @@
// Media Viewer Components
export * from './dees-image-viewer/index.js';
export * from './dees-audio-viewer/index.js';
export * from './dees-video-viewer/index.js';
export * from './dees-preview/index.js';
// PDF Components
export * from './dees-pdf/index.js'; // @deprecated - Use dees-pdf-viewer or dees-tile-pdf instead
export * from './dees-pdf-preview/index.js'; // @deprecated - Use dees-tile-pdf instead
export * from './dees-pdf-shared/index.js';
export * from './dees-pdf-viewer/index.js';
// Tile Components
export * from './dees-tile-shared/index.js';
export * from './dees-tile-pdf/index.js';
export * from './dees-tile-image/index.js';
export * from './dees-tile-audio/index.js';
export * from './dees-tile-video/index.js';
export * from './dees-tile-note/index.js';
export * from './dees-tile-folder/index.js';