411 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|