477 lines
14 KiB
TypeScript
477 lines
14 KiB
TypeScript
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||
import type { IBlock } from '../../wysiwyg.types.js';
|
||
import { cssManager } from '@design.estate/dees-element';
|
||
|
||
/**
|
||
* AttachmentBlockHandler - Handles file attachments
|
||
*
|
||
* Features:
|
||
* - Multiple file upload support
|
||
* - Click to upload or drag and drop
|
||
* - File type icons
|
||
* - Remove individual files
|
||
* - Base64 encoding (TODO: server upload in production)
|
||
*/
|
||
export class AttachmentBlockHandler extends BaseBlockHandler {
|
||
type = 'attachment';
|
||
|
||
render(block: IBlock, isSelected: boolean): string {
|
||
const files = block.metadata?.files || [];
|
||
|
||
return `
|
||
<div class="attachment-block-container${isSelected ? ' selected' : ''}"
|
||
data-block-id="${block.id}"
|
||
tabindex="0">
|
||
<div class="attachment-header">
|
||
<div class="attachment-icon">📎</div>
|
||
<div class="attachment-title">File Attachments</div>
|
||
</div>
|
||
<div class="attachment-list">
|
||
${files.length > 0 ? this.renderFiles(files) : this.renderPlaceholder()}
|
||
</div>
|
||
<input type="file"
|
||
class="attachment-file-input"
|
||
multiple
|
||
style="display: none;" />
|
||
${files.length > 0 ? '<button class="add-more-files">Add More Files</button>' : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderPlaceholder(): string {
|
||
return `
|
||
<div class="attachment-placeholder">
|
||
<div class="placeholder-text">Click to add files</div>
|
||
<div class="placeholder-hint">or drag and drop</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderFiles(files: any[]): string {
|
||
return files.map((file: any) => `
|
||
<div class="attachment-item" data-file-id="${file.id}">
|
||
<div class="file-icon">${this.getFileIcon(file.type)}</div>
|
||
<div class="file-info">
|
||
<div class="file-name">${this.escapeHtml(file.name)}</div>
|
||
<div class="file-size">${this.formatFileSize(file.size)}</div>
|
||
</div>
|
||
<button class="remove-file" data-file-id="${file.id}">×</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||
const container = element.querySelector('.attachment-block-container') as HTMLElement;
|
||
const fileInput = element.querySelector('.attachment-file-input') as HTMLInputElement;
|
||
|
||
if (!container || !fileInput) {
|
||
console.error('AttachmentBlockHandler: Could not find required elements');
|
||
return;
|
||
}
|
||
|
||
// Initialize files array if needed
|
||
if (!block.metadata) block.metadata = {};
|
||
if (!block.metadata.files) block.metadata.files = [];
|
||
|
||
// Click to upload on placeholder
|
||
const placeholder = container.querySelector('.attachment-placeholder');
|
||
if (placeholder) {
|
||
placeholder.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
fileInput.click();
|
||
});
|
||
}
|
||
|
||
// Add more files button
|
||
const addMoreBtn = container.querySelector('.add-more-files') as HTMLButtonElement;
|
||
if (addMoreBtn) {
|
||
addMoreBtn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
fileInput.click();
|
||
});
|
||
}
|
||
|
||
// File input change
|
||
fileInput.addEventListener('change', async (e) => {
|
||
const input = e.target as HTMLInputElement;
|
||
const files = input.files;
|
||
if (files && files.length > 0) {
|
||
await this.handleFileAttachments(files, block, handlers);
|
||
input.value = ''; // Clear input for next selection
|
||
}
|
||
});
|
||
|
||
// Remove file buttons
|
||
container.addEventListener('click', (e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (target.classList.contains('remove-file')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const fileId = target.getAttribute('data-file-id');
|
||
if (fileId) {
|
||
this.removeFile(fileId, block, handlers);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Drag and drop
|
||
container.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
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 files = e.dataTransfer?.files;
|
||
if (files && files.length > 0) {
|
||
await this.handleFileAttachments(files, 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') {
|
||
// Only remove all files if container is focused, not when removing individual files
|
||
if (document.activeElement === container && block.metadata?.files?.length > 0) {
|
||
e.preventDefault();
|
||
block.metadata.files = [];
|
||
handlers.onRequestUpdate?.();
|
||
return;
|
||
}
|
||
}
|
||
handlers.onKeyDown(e);
|
||
});
|
||
}
|
||
|
||
private async handleFileAttachments(
|
||
files: FileList,
|
||
block: IBlock,
|
||
handlers: IBlockEventHandlers
|
||
): Promise<void> {
|
||
if (!block.metadata) block.metadata = {};
|
||
if (!block.metadata.files) block.metadata.files = [];
|
||
|
||
for (const file of Array.from(files)) {
|
||
try {
|
||
const dataUrl = await this.fileToDataUrl(file);
|
||
const fileData = {
|
||
id: this.generateId(),
|
||
name: file.name,
|
||
size: file.size,
|
||
type: file.type,
|
||
data: dataUrl
|
||
};
|
||
|
||
block.metadata.files.push(fileData);
|
||
} catch (error) {
|
||
console.error('Failed to attach file:', file.name, error);
|
||
}
|
||
}
|
||
|
||
// Update block content with file count
|
||
block.content = `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`;
|
||
|
||
// Request UI update
|
||
handlers.onRequestUpdate?.();
|
||
}
|
||
|
||
private removeFile(fileId: string, block: IBlock, handlers: IBlockEventHandlers): void {
|
||
if (!block.metadata?.files) return;
|
||
|
||
block.metadata.files = block.metadata.files.filter((f: any) => f.id !== fileId);
|
||
|
||
// Update content
|
||
block.content = block.metadata.files.length > 0
|
||
? `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`
|
||
: '';
|
||
|
||
// Request UI update
|
||
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 getFileIcon(mimeType: string): string {
|
||
if (mimeType.startsWith('image/')) return '🖼️';
|
||
if (mimeType.startsWith('video/')) return '🎥';
|
||
if (mimeType.startsWith('audio/')) return '🎵';
|
||
if (mimeType.includes('pdf')) return '📄';
|
||
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('tar')) return '🗄️';
|
||
if (mimeType.includes('sheet')) return '📊';
|
||
if (mimeType.includes('document') || mimeType.includes('msword')) return '📝';
|
||
if (mimeType.includes('presentation')) return '📋';
|
||
if (mimeType.includes('text')) return '📃';
|
||
return '📁';
|
||
}
|
||
|
||
private formatFileSize(bytes: number): string {
|
||
if (bytes === 0) return '0 Bytes';
|
||
const k = 1024;
|
||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
}
|
||
|
||
private generateId(): string {
|
||
return `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
|
||
private escapeHtml(text: string): string {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
getContent(element: HTMLElement): string {
|
||
// Content is the description of attached files
|
||
const block = this.getBlockFromElement(element);
|
||
return block?.content || '';
|
||
}
|
||
|
||
setContent(element: HTMLElement, content: string): void {
|
||
// Content is the description of attached files
|
||
const block = this.getBlockFromElement(element);
|
||
if (block) {
|
||
block.content = content;
|
||
}
|
||
}
|
||
|
||
private getBlockFromElement(element: HTMLElement): IBlock | null {
|
||
const container = element.querySelector('.attachment-block-container');
|
||
const blockId = container?.getAttribute('data-block-id');
|
||
if (!blockId) return null;
|
||
|
||
// Simplified version - in real implementation would need access to block data
|
||
return {
|
||
id: blockId,
|
||
type: 'attachment',
|
||
content: '',
|
||
metadata: {}
|
||
};
|
||
}
|
||
|
||
getCursorPosition(element: HTMLElement): number | null {
|
||
return null; // Attachment blocks 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('.attachment-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; // Attachment blocks can't be split
|
||
}
|
||
|
||
getStyles(): string {
|
||
return `
|
||
/* Attachment Block Container */
|
||
.attachment-block-container {
|
||
position: relative;
|
||
margin: 12px 0;
|
||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
transition: all 0.15s ease;
|
||
outline: none;
|
||
background: ${cssManager.bdTheme('#ffffff', '#111827')};
|
||
}
|
||
|
||
.attachment-block-container.selected {
|
||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||
}
|
||
|
||
.attachment-block-container.drag-over {
|
||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||
border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||
}
|
||
|
||
/* Header */
|
||
.attachment-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||
}
|
||
|
||
.attachment-icon {
|
||
font-size: 18px;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.attachment-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||
}
|
||
|
||
/* File List */
|
||
.attachment-list {
|
||
padding: 8px;
|
||
min-height: 80px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* Placeholder */
|
||
.attachment-placeholder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.attachment-placeholder:hover {
|
||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||
}
|
||
|
||
.placeholder-text {
|
||
font-size: 14px;
|
||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.placeholder-hint {
|
||
font-size: 12px;
|
||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||
}
|
||
|
||
/* File Items */
|
||
.attachment-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 8px 12px;
|
||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||
border-radius: 4px;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.attachment-item:hover {
|
||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||
}
|
||
|
||
.file-icon {
|
||
font-size: 20px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.file-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.file-name {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.file-size {
|
||
font-size: 11px;
|
||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.remove-file {
|
||
flex-shrink: 0;
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
border: 1px solid transparent;
|
||
border-radius: 4px;
|
||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
padding: 0;
|
||
}
|
||
|
||
.remove-file:hover {
|
||
background: ${cssManager.bdTheme('#fee2e2', '#991b1b')};
|
||
border-color: ${cssManager.bdTheme('#fca5a5', '#dc2626')};
|
||
color: ${cssManager.bdTheme('#dc2626', '#fca5a5')};
|
||
}
|
||
|
||
/* Add More Files Button */
|
||
.add-more-files {
|
||
margin: 8px;
|
||
padding: 6px 12px;
|
||
background: transparent;
|
||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
}
|
||
|
||
.add-more-files:hover {
|
||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||
}
|
||
|
||
/* Hidden file input */
|
||
.attachment-file-input {
|
||
display: none !important;
|
||
}
|
||
`;
|
||
}
|
||
} |