feat(components): add large set of new UI components and demos, reorganize groups, and bump a few dependencies
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user