import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
/**
* ImageBlockHandler - Handles image upload, display, and interactions
*
* Features:
* - Click to upload
* - Drag and drop support
* - Base64 encoding (TODO: server upload in production)
* - Loading states
* - Alt text from filename
*/
export class ImageBlockHandler extends BaseBlockHandler {
type = 'image';
render(block: IBlock, isSelected: boolean): string {
const imageUrl = block.metadata?.url;
const altText = block.content || 'Image';
const isLoading = block.metadata?.loading;
return `
${isLoading ? this.renderLoading() :
imageUrl ? this.renderImage(imageUrl, altText) :
this.renderPlaceholder()}
`;
}
private renderPlaceholder(): string {
return `
Click to upload an image
or drag and drop
`;
}
private renderImage(url: string, altText: string): string {
return `
`;
}
private renderLoading(): string {
return `
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const container = element.querySelector('.image-block-container') as HTMLElement;
const fileInput = element.querySelector('.image-file-input') as HTMLInputElement;
if (!container) {
console.error('ImageBlockHandler: Could not find container');
return;
}
if (!fileInput) {
console.error('ImageBlockHandler: Could not find file input');
return;
}
// Click to upload (only on placeholder)
const placeholder = container.querySelector('.image-upload-placeholder');
if (placeholder) {
placeholder.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('ImageBlockHandler: Placeholder clicked, opening file selector');
fileInput.click();
});
}
// Container click for focus
container.addEventListener('click', () => {
handlers.onFocus();
});
// File input change
fileInput.addEventListener('change', async (e) => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
console.log('ImageBlockHandler: File selected:', file.name);
await this.handleFileUpload(file, block, handlers);
}
});
// Drag and drop
container.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
if (!block.metadata?.url) {
container.classList.add('drag-over');
}
});
container.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
});
container.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/') && !block.metadata?.url) {
await this.handleFileUpload(file, block, handlers);
}
});
// Focus/blur
container.addEventListener('focus', () => handlers.onFocus());
container.addEventListener('blur', () => handlers.onBlur());
// Keyboard navigation
container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (block.metadata?.url) {
// Clear the image
block.metadata.url = undefined;
block.metadata.loading = false;
block.content = '';
handlers.onInput(new InputEvent('input'));
return;
}
}
handlers.onKeyDown(e);
});
}
private async handleFileUpload(
file: File,
block: IBlock,
handlers: IBlockEventHandlers
): Promise {
console.log('ImageBlockHandler: Starting file upload', {
fileName: file.name,
fileSize: file.size,
blockId: block.id
});
// Validate file
if (!file.type.startsWith('image/')) {
console.error('Invalid file type:', file.type);
return;
}
// Check file size (10MB limit)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
console.error('File too large. Maximum size is 10MB');
return;
}
// Set loading state
if (!block.metadata) block.metadata = {};
block.metadata.loading = true;
block.metadata.fileName = file.name;
block.metadata.fileSize = file.size;
block.metadata.mimeType = file.type;
console.log('ImageBlockHandler: Set loading state, requesting update');
// Request immediate UI update for loading state
handlers.onRequestUpdate?.();
try {
// Convert to base64
const dataUrl = await this.fileToDataUrl(file);
// Update block
block.metadata.url = dataUrl;
block.metadata.loading = false;
// Set default alt text from filename
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
block.content = nameWithoutExt;
console.log('ImageBlockHandler: Upload complete, requesting update', {
hasUrl: !!block.metadata.url,
urlLength: dataUrl.length,
altText: block.content
});
// Request immediate UI update to show uploaded image
handlers.onRequestUpdate?.();
} catch (error) {
console.error('Failed to upload image:', error);
block.metadata.loading = false;
// Request UI update to clear loading state
handlers.onRequestUpdate?.();
}
}
private fileToDataUrl(file: File): Promise {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result;
if (typeof result === 'string') {
resolve(result);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
// Content is the alt text
const block = this.getBlockFromElement(element);
return block?.content || '';
}
setContent(element: HTMLElement, content: string): void {
// Content is the alt text
const block = this.getBlockFromElement(element);
if (block) {
block.content = content;
}
}
private getBlockFromElement(element: HTMLElement): IBlock | null {
const container = element.querySelector('.image-block-container');
const blockId = container?.getAttribute('data-block-id');
if (!blockId) return null;
// This is a simplified version - in real implementation,
// we'd need access to the block data
return {
id: blockId,
type: 'image',
content: '',
metadata: {}
};
}
getCursorPosition(element: HTMLElement): number | null {
return null; // Images don't have cursor position
}
setCursorToStart(element: HTMLElement): void {
this.focus(element);
}
setCursorToEnd(element: HTMLElement): void {
this.focus(element);
}
focus(element: HTMLElement): void {
const container = element.querySelector('.image-block-container') as HTMLElement;
container?.focus();
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
this.focus(element);
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
return null; // Images can't be split
}
getStyles(): string {
return `
/* Image Block Container */
.image-block-container {
position: relative;
margin: 12px 0;
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
outline: none;
cursor: pointer;
}
.image-block-container.selected {
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')};
}
/* Upload Placeholder */
.image-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
border: 2px dashed ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
transition: all 0.15s ease;
}
.image-block-container:hover .image-upload-placeholder {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
}
.image-block-container.drag-over .image-upload-placeholder {
border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
background: ${cssManager.bdTheme('#eff6ff', '#1e1b4b')};
}
.upload-icon {
margin-bottom: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
}
.upload-text {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
margin-bottom: 4px;
}
.upload-hint {
font-size: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Image Container */
.image-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
}
.image-container img {
max-width: 100%;
height: auto;
display: block;
border-radius: 4px;
}
/* Loading State */
.image-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-top-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 14px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
/* File input hidden */
.image-file-input {
display: none !important;
}
`;
}
}