feat(components): add large set of new UI components and demos, reorganize groups, and bump a few dependencies
This commit is contained in:
611
ts_web/elements/00group-media/dees-audio-viewer/component.ts
Normal file
611
ts_web/elements/00group-media/dees-audio-viewer/component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
69
ts_web/elements/00group-media/dees-audio-viewer/demo.ts
Normal file
69
ts_web/elements/00group-media/dees-audio-viewer/demo.ts
Normal 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>
|
||||
`;
|
||||
1
ts_web/elements/00group-media/dees-audio-viewer/index.ts
Normal file
1
ts_web/elements/00group-media/dees-audio-viewer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
410
ts_web/elements/00group-media/dees-image-viewer/component.ts
Normal file
410
ts_web/elements/00group-media/dees-image-viewer/component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
84
ts_web/elements/00group-media/dees-image-viewer/demo.ts
Normal file
84
ts_web/elements/00group-media/dees-image-viewer/demo.ts
Normal 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>
|
||||
`;
|
||||
1
ts_web/elements/00group-media/dees-image-viewer/index.ts
Normal file
1
ts_web/elements/00group-media/dees-image-viewer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
24
ts_web/elements/00group-media/dees-pdf-preview/component.ts
Normal file
24
ts_web/elements/00group-media/dees-pdf-preview/component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
189
ts_web/elements/00group-media/dees-pdf-preview/demo.ts
Normal file
189
ts_web/elements/00group-media/dees-pdf-preview/demo.ts
Normal 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>
|
||||
`;
|
||||
};
|
||||
1
ts_web/elements/00group-media/dees-pdf-preview/index.ts
Normal file
1
ts_web/elements/00group-media/dees-pdf-preview/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
223
ts_web/elements/00group-media/dees-pdf-preview/styles.ts
Normal file
223
ts_web/elements/00group-media/dees-pdf-preview/styles.ts
Normal 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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
135
ts_web/elements/00group-media/dees-pdf-shared/CanvasPool.ts
Normal file
135
ts_web/elements/00group-media/dees-pdf-shared/CanvasPool.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
ts_web/elements/00group-media/dees-pdf-shared/PdfManager.ts
Normal file
36
ts_web/elements/00group-media/dees-pdf-shared/PdfManager.ts
Normal 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
|
||||
}
|
||||
3
ts_web/elements/00group-media/dees-pdf-shared/index.ts
Normal file
3
ts_web/elements/00group-media/dees-pdf-shared/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './CanvasPool.js';
|
||||
export * from './PdfManager.js';
|
||||
export * from './utils.js';
|
||||
98
ts_web/elements/00group-media/dees-pdf-shared/utils.ts
Normal file
98
ts_web/elements/00group-media/dees-pdf-shared/utils.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
1029
ts_web/elements/00group-media/dees-pdf-viewer/component.ts
Normal file
1029
ts_web/elements/00group-media/dees-pdf-viewer/component.ts
Normal file
File diff suppressed because it is too large
Load Diff
69
ts_web/elements/00group-media/dees-pdf-viewer/demo.ts
Normal file
69
ts_web/elements/00group-media/dees-pdf-viewer/demo.ts
Normal 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>
|
||||
`;
|
||||
1
ts_web/elements/00group-media/dees-pdf-viewer/index.ts
Normal file
1
ts_web/elements/00group-media/dees-pdf-viewer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
291
ts_web/elements/00group-media/dees-pdf-viewer/styles.ts
Normal file
291
ts_web/elements/00group-media/dees-pdf-viewer/styles.ts
Normal 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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
161
ts_web/elements/00group-media/dees-pdf/component.ts
Normal file
161
ts_web/elements/00group-media/dees-pdf/component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
1
ts_web/elements/00group-media/dees-pdf/index.ts
Normal file
1
ts_web/elements/00group-media/dees-pdf/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
122
ts_web/elements/00group-media/dees-preview/dees-preview.demo.ts
Normal file
122
ts_web/elements/00group-media/dees-preview/dees-preview.demo.ts
Normal 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>
|
||||
`;
|
||||
507
ts_web/elements/00group-media/dees-preview/dees-preview.ts
Normal file
507
ts_web/elements/00group-media/dees-preview/dees-preview.ts
Normal 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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
1
ts_web/elements/00group-media/dees-preview/index.ts
Normal file
1
ts_web/elements/00group-media/dees-preview/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dees-preview.js';
|
||||
344
ts_web/elements/00group-media/dees-tile-audio/component.ts
Normal file
344
ts_web/elements/00group-media/dees-tile-audio/component.ts
Normal 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')}`;
|
||||
}
|
||||
}
|
||||
77
ts_web/elements/00group-media/dees-tile-audio/demo.ts
Normal file
77
ts_web/elements/00group-media/dees-tile-audio/demo.ts
Normal 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>
|
||||
`;
|
||||
1
ts_web/elements/00group-media/dees-tile-audio/index.ts
Normal file
1
ts_web/elements/00group-media/dees-tile-audio/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
181
ts_web/elements/00group-media/dees-tile-folder/component.ts
Normal file
181
ts_web/elements/00group-media/dees-tile-folder/component.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
122
ts_web/elements/00group-media/dees-tile-folder/demo.ts
Normal file
122
ts_web/elements/00group-media/dees-tile-folder/demo.ts
Normal 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>
|
||||
`;
|
||||
};
|
||||
1
ts_web/elements/00group-media/dees-tile-folder/index.ts
Normal file
1
ts_web/elements/00group-media/dees-tile-folder/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
172
ts_web/elements/00group-media/dees-tile-image/component.ts
Normal file
172
ts_web/elements/00group-media/dees-tile-image/component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
84
ts_web/elements/00group-media/dees-tile-image/demo.ts
Normal file
84
ts_web/elements/00group-media/dees-tile-image/demo.ts
Normal 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>
|
||||
`;
|
||||
1
ts_web/elements/00group-media/dees-tile-image/index.ts
Normal file
1
ts_web/elements/00group-media/dees-tile-image/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
231
ts_web/elements/00group-media/dees-tile-note/component.ts
Normal file
231
ts_web/elements/00group-media/dees-tile-note/component.ts
Normal 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 `1–12 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}`;
|
||||
}
|
||||
}
|
||||
135
ts_web/elements/00group-media/dees-tile-note/demo.ts
Normal file
135
ts_web/elements/00group-media/dees-tile-note/demo.ts
Normal 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>
|
||||
`;
|
||||
};
|
||||
1
ts_web/elements/00group-media/dees-tile-note/index.ts
Normal file
1
ts_web/elements/00group-media/dees-tile-note/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
421
ts_web/elements/00group-media/dees-tile-pdf/component.ts
Normal file
421
ts_web/elements/00group-media/dees-tile-pdf/component.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
128
ts_web/elements/00group-media/dees-tile-pdf/demo.ts
Normal file
128
ts_web/elements/00group-media/dees-tile-pdf/demo.ts
Normal 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>
|
||||
`;
|
||||
};
|
||||
1
ts_web/elements/00group-media/dees-tile-pdf/index.ts
Normal file
1
ts_web/elements/00group-media/dees-tile-pdf/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
61
ts_web/elements/00group-media/dees-tile-pdf/styles.ts
Normal file
61
ts_web/elements/00group-media/dees-tile-pdf/styles.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
130
ts_web/elements/00group-media/dees-tile-shared/DeesTileBase.ts
Normal file
130
ts_web/elements/00group-media/dees-tile-shared/DeesTileBase.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
2
ts_web/elements/00group-media/dees-tile-shared/index.ts
Normal file
2
ts_web/elements/00group-media/dees-tile-shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DeesTileBase } from './DeesTileBase.js';
|
||||
export { tileBaseStyles } from './styles.js';
|
||||
213
ts_web/elements/00group-media/dees-tile-shared/styles.ts
Normal file
213
ts_web/elements/00group-media/dees-tile-shared/styles.ts
Normal 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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
317
ts_web/elements/00group-media/dees-tile-video/component.ts
Normal file
317
ts_web/elements/00group-media/dees-tile-video/component.ts
Normal 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')}`;
|
||||
}
|
||||
}
|
||||
79
ts_web/elements/00group-media/dees-tile-video/demo.ts
Normal file
79
ts_web/elements/00group-media/dees-tile-video/demo.ts
Normal 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>
|
||||
`;
|
||||
1
ts_web/elements/00group-media/dees-tile-video/index.ts
Normal file
1
ts_web/elements/00group-media/dees-tile-video/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
506
ts_web/elements/00group-media/dees-video-viewer/component.ts
Normal file
506
ts_web/elements/00group-media/dees-video-viewer/component.ts
Normal 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')}`;
|
||||
}
|
||||
}
|
||||
63
ts_web/elements/00group-media/dees-video-viewer/demo.ts
Normal file
63
ts_web/elements/00group-media/dees-video-viewer/demo.ts
Normal 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>
|
||||
`;
|
||||
1
ts_web/elements/00group-media/dees-video-viewer/index.ts
Normal file
1
ts_web/elements/00group-media/dees-video-viewer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
20
ts_web/elements/00group-media/index.ts
Normal file
20
ts_web/elements/00group-media/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user