406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
![]() |
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 `
|
||
|
<div class="image-block-container${isSelected ? ' selected' : ''}"
|
||
|
data-block-id="${block.id}"
|
||
|
data-has-image="${!!imageUrl}"
|
||
|
tabindex="0">
|
||
|
${isLoading ? this.renderLoading() :
|
||
|
imageUrl ? this.renderImage(imageUrl, altText) :
|
||
|
this.renderPlaceholder()}
|
||
|
<input type="file"
|
||
|
class="image-file-input"
|
||
|
accept="image/*"
|
||
|
style="display: none;" />
|
||
|
</div>
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
private renderPlaceholder(): string {
|
||
|
return `
|
||
|
<div class="image-upload-placeholder" style="cursor: pointer;">
|
||
|
<div class="upload-icon" style="pointer-events: none;">
|
||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
|
<polyline points="21 15 16 10 5 21"/>
|
||
|
</svg>
|
||
|
</div>
|
||
|
<div class="upload-text" style="pointer-events: none;">Click to upload an image</div>
|
||
|
<div class="upload-hint" style="pointer-events: none;">or drag and drop</div>
|
||
|
</div>
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
private renderImage(url: string, altText: string): string {
|
||
|
return `
|
||
|
<div class="image-container">
|
||
|
<img src="${url}" alt="${this.escapeHtml(altText)}" />
|
||
|
</div>
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
private renderLoading(): string {
|
||
|
return `
|
||
|
<div class="image-loading">
|
||
|
<div class="loading-spinner"></div>
|
||
|
<div class="loading-text">Uploading image...</div>
|
||
|
</div>
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
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<void> {
|
||
|
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<string> {
|
||
|
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;
|
||
|
}
|
||
|
`;
|
||
|
}
|
||
|
}
|