Files
dees-catalog/ts_web/elements/00group-media/dees-image-viewer/component.ts

411 lines
11 KiB
TypeScript

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;
}
}
}