From fca3638f7ff1f586d10c586bbe93a719cfbd46ff Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 24 Jun 2025 17:16:13 +0000 Subject: [PATCH] implement image upload --- ts_web/elements/wysiwyg/dees-input-wysiwyg.ts | 9 +- ts_web/elements/wysiwyg/dees-wysiwyg-block.ts | 281 ++++++++++++++++++ ts_web/elements/wysiwyg/wysiwyg.converters.ts | 31 ++ ts_web/elements/wysiwyg/wysiwyg.formatting.ts | 12 - ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts | 1 + 5 files changed, 321 insertions(+), 13 deletions(-) diff --git a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts index b45d306..eb7d81a 100644 --- a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts +++ b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts @@ -533,6 +533,10 @@ export class DeesInputWysiwyg extends DeesInputBase { currentBlock.metadata = { listType: 'bullet' }; // For lists, ensure we start with empty content currentBlock.content = ''; + } else if (type === 'image') { + // For image blocks, clear content and set empty metadata + currentBlock.content = ''; + currentBlock.metadata = { url: '', loading: false }; } else { // For all other block types, ensure content is clean currentBlock.content = currentBlock.content || ''; @@ -556,8 +560,11 @@ export class DeesInputWysiwyg extends DeesInputBase { blockComponent.focusListItem(); } }); - } else if (type !== 'divider') { + } else if (type !== 'divider' && type !== 'image') { this.blockOperations.focusBlock(currentBlock.id, 'start'); + } else if (type === 'image') { + // Focus the image block (which will show the upload interface) + this.blockOperations.focusBlock(currentBlock.id); } } diff --git a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts index 8c58453..2a9751d 100644 --- a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts +++ b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts @@ -259,6 +259,92 @@ export class DeesWysiwygBlock extends DeesElement { padding-left: 8px; padding-right: 8px; } + + /* Image block styles */ + .block.image { + min-height: 200px; + padding: 0; + margin: 16px 0; + border-radius: 8px; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; + } + + .image-upload-placeholder { + width: 100%; + height: 200px; + background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')}; + border: 2px dashed ${cssManager.bdTheme('#d0d0d0', '#404040')}; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + } + + .image-upload-placeholder:hover { + background: ${cssManager.bdTheme('#f0f0f0', '#222222')}; + border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + } + + .image-upload-placeholder:active { + transform: scale(0.98); + } + + .image-upload-placeholder.drag-over { + background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')}; + border-color: ${cssManager.bdTheme('#2196F3', '#64b5f6')}; + } + + .upload-icon { + font-size: 48px; + margin-bottom: 12px; + opacity: 0.7; + } + + .upload-text { + font-size: 16px; + color: ${cssManager.bdTheme('#666', '#999')}; + margin-bottom: 8px; + } + + .upload-hint { + font-size: 13px; + color: ${cssManager.bdTheme('#999', '#666')}; + } + + .image-container { + width: 100%; + position: relative; + } + + .image-container img { + width: 100%; + height: auto; + display: block; + border-radius: 8px; + } + + .image-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 16px 24px; + background: rgba(0, 0, 0, 0.8); + color: white; + border-radius: 8px; + font-size: 14px; + } + + input[type="file"] { + display: none; + } `, ]; @@ -286,6 +372,12 @@ export class DeesWysiwygBlock extends DeesElement { container.innerHTML = this.renderBlockContent(); } + // Handle image block setup + if (this.block.type === 'image') { + this.setupImageBlock(); + return; // Image blocks don't need the standard editable setup + } + // Now find the actual editable block element const editableBlock = this.block.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement @@ -505,6 +597,32 @@ export class DeesWysiwygBlock extends DeesElement { `; } + if (this.block.type === 'image') { + const selectedClass = this.isSelected ? ' selected' : ''; + const imageUrl = this.block.metadata?.url || ''; + const isLoading = this.block.metadata?.loading || false; + + return ` +
+ ${isLoading ? ` +
Uploading image...
+ ` : ''} + ${imageUrl ? ` +
+ ${this.block.content || 'Uploaded image'} +
+ ` : ` +
+
🖼️
+
Click to upload an image
+
or drag and drop
+
+ + `} +
+ `; + } + const placeholder = this.getPlaceholder(); const selectedClass = this.isSelected ? ' selected' : ''; return ` @@ -528,6 +646,8 @@ export class DeesWysiwygBlock extends DeesElement { return 'Heading 3'; case 'quote': return 'Quote'; + case 'image': + return 'Click to upload an image'; default: return ''; } @@ -535,6 +655,15 @@ export class DeesWysiwygBlock extends DeesElement { public focus(): void { + // Image blocks don't focus in the traditional way + if (this.block?.type === 'image') { + const imageBlock = this.shadowRoot?.querySelector('.block.image') as HTMLDivElement; + if (imageBlock) { + imageBlock.focus(); + } + return; + } + // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement @@ -558,6 +687,12 @@ export class DeesWysiwygBlock extends DeesElement { } public focusWithCursor(position: 'start' | 'end' | number = 'end'): void { + // Image blocks don't support cursor positioning + if (this.block?.type === 'image') { + this.focus(); + return; + } + // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement @@ -654,6 +789,11 @@ export class DeesWysiwygBlock extends DeesElement { } public getContent(): string { + // Handle image blocks specially + if (this.block?.type === 'image') { + return this.block.content || ''; // Image blocks store alt text in content + } + // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement @@ -726,12 +866,153 @@ export class DeesWysiwygBlock extends DeesElement { } } + /** + * Setup image block functionality + */ + private setupImageBlock(): void { + const imageBlock = this.shadowRoot?.querySelector('.block.image') as HTMLDivElement; + if (!imageBlock) return; + + // Make the image block focusable + imageBlock.setAttribute('tabindex', '0'); + + // Handle click on upload placeholder + const uploadPlaceholder = imageBlock.querySelector('.image-upload-placeholder'); + const fileInput = imageBlock.querySelector('input[type="file"]') as HTMLInputElement; + + if (uploadPlaceholder && fileInput) { + uploadPlaceholder.addEventListener('click', () => { + fileInput.click(); + }); + + fileInput.addEventListener('change', (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + this.handleImageUpload(file); + } + }); + + // Handle drag and drop + imageBlock.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + uploadPlaceholder.classList.add('drag-over'); + }); + + imageBlock.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + uploadPlaceholder.classList.remove('drag-over'); + }); + + imageBlock.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + uploadPlaceholder.classList.remove('drag-over'); + + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const file = files[0]; + if (file.type.startsWith('image/')) { + this.handleImageUpload(file); + } + } + }); + } + + // Handle focus/blur for the image block + imageBlock.addEventListener('focus', () => { + this.handlers?.onFocus?.(); + }); + + imageBlock.addEventListener('blur', () => { + this.handlers?.onBlur?.(); + }); + + // Handle keyboard events + imageBlock.addEventListener('keydown', (e) => { + this.handlers?.onKeyDown?.(e); + }); + } + + /** + * Handle image file upload + */ + private async handleImageUpload(file: File): Promise { + // Check file size (max 10MB) + if (file.size > 10 * 1024 * 1024) { + alert('Image size must be less than 10MB'); + return; + } + + // Update block to show loading state + this.block.metadata = { ...this.block.metadata, loading: true }; + const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement; + if (container) { + container.innerHTML = this.renderBlockContent(); + this.setupImageBlock(); // Re-setup event handlers + } + + try { + // Convert to base64 for now (in production, you'd upload to a server) + const reader = new FileReader(); + reader.onload = (e) => { + const base64 = e.target?.result as string; + + // Update block with image URL + this.block.metadata = { + ...this.block.metadata, + url: base64, + loading: false, + fileName: file.name, + fileSize: file.size, + mimeType: file.type + }; + + // Set alt text as content + this.block.content = file.name.replace(/\.[^/.]+$/, ''); // Remove extension + + // Re-render + if (container) { + container.innerHTML = this.renderBlockContent(); + } + + // Notify parent component of the change + this.handlers?.onInput?.(new InputEvent('input')); + }; + + reader.onerror = () => { + alert('Failed to read image file'); + this.block.metadata = { ...this.block.metadata, loading: false }; + if (container) { + container.innerHTML = this.renderBlockContent(); + this.setupImageBlock(); + } + }; + + reader.readAsDataURL(file); + } catch (error) { + console.error('Error uploading image:', error); + alert('Failed to upload image'); + this.block.metadata = { ...this.block.metadata, loading: false }; + if (container) { + container.innerHTML = this.renderBlockContent(); + this.setupImageBlock(); + } + } + } + /** * Gets content split at cursor position */ public getSplitContent(): { before: string; after: string } | null { console.log('getSplitContent: Starting...'); + // Image blocks can't be split + if (this.block?.type === 'image') { + return null; + } + // Get the actual editable element first const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement diff --git a/ts_web/elements/wysiwyg/wysiwyg.converters.ts b/ts_web/elements/wysiwyg/wysiwyg.converters.ts index 2bdbc9c..85ecbe1 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.converters.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.converters.ts @@ -37,6 +37,13 @@ export class WysiwygConverters { return ''; case 'divider': return '
'; + case 'image': + const imageUrl = block.metadata?.url; + if (imageUrl) { + const altText = this.escapeHtml(block.content || 'Image'); + return `${altText}`; + } + return ''; default: return `

${content}

`; } @@ -67,6 +74,10 @@ export class WysiwygConverters { } case 'divider': return '---'; + case 'image': + const imageUrl = block.metadata?.url; + const altText = block.content || 'Image'; + return imageUrl ? `![${altText}](${imageUrl})` : ''; default: return block.content; } @@ -152,6 +163,15 @@ export class WysiwygConverters { content: ' ', }); break; + case 'img': + const imgElement = element as HTMLImageElement; + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + type: 'image', + content: imgElement.alt || '', + metadata: { url: imgElement.src } + }); + break; default: // Process children for other elements element.childNodes.forEach(child => processNode(child)); @@ -237,6 +257,17 @@ export class WysiwygConverters { type: 'divider', content: ' ', }); + } else if (line.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/)) { + // Parse markdown image syntax ![alt](url) + const match = line.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/); + if (match) { + blocks.push({ + id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + type: 'image', + content: match[1] || '', + metadata: { url: match[2] } + }); + } } else if (line.trim()) { blocks.push({ id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, diff --git a/ts_web/elements/wysiwyg/wysiwyg.formatting.ts b/ts_web/elements/wysiwyg/wysiwyg.formatting.ts index 5e4b80a..dd1ad36 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.formatting.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.formatting.ts @@ -114,14 +114,11 @@ export class WysiwygFormatting { // Check if ANY part of the selection contains this formatting const hasFormatting = this.selectionContainsTag(range, tagName); - console.log(`Formatting check for <${tagName}>: ${hasFormatting ? 'HAS formatting' : 'NO formatting'}`); if (hasFormatting) { - console.log(`Removing <${tagName}> formatting from selection`); // Remove all instances of this tag from the selection this.removeTagFromSelection(range, tagName); } else { - console.log(`Adding <${tagName}> formatting to selection`); // Wrap selection with the tag const wrapper = document.createElement(tagName); try { @@ -144,18 +141,13 @@ export class WysiwygFormatting { * Check if the selection contains or is within any instances of a tag */ private static selectionContainsTag(range: Range, tagName: string): boolean { - console.log(`Checking if selection contains <${tagName}>...`); - // First check: Are we inside a tag? (even if selection doesn't include the tag) let node: Node | null = range.startContainer; - console.log('Start container:', range.startContainer, 'type:', range.startContainer.nodeType); while (node && node !== range.commonAncestorContainer.ownerDocument) { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; - console.log(` Checking parent element: <${element.tagName.toLowerCase()}>`); if (element.tagName.toLowerCase() === tagName) { - console.log(` ✓ Found <${tagName}> as parent of start container`); return true; } } @@ -164,14 +156,11 @@ export class WysiwygFormatting { // Also check the end container node = range.endContainer; - console.log('End container:', range.endContainer, 'type:', range.endContainer.nodeType); while (node && node !== range.commonAncestorContainer.ownerDocument) { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; - console.log(` Checking parent element: <${element.tagName.toLowerCase()}>`); if (element.tagName.toLowerCase() === tagName) { - console.log(` ✓ Found <${tagName}> as parent of end container`); return true; } } @@ -183,7 +172,6 @@ export class WysiwygFormatting { const contents = range.cloneContents(); tempDiv.appendChild(contents); const tags = tempDiv.getElementsByTagName(tagName); - console.log(` Selection contains ${tags.length} complete <${tagName}> tags`); return tags.length > 0; } diff --git a/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts b/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts index 93f076c..eba9630 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts @@ -56,6 +56,7 @@ export class WysiwygShortcuts { { type: 'quote', label: 'Quote', icon: '"' }, { type: 'code', label: 'Code', icon: '<>' }, { type: 'list', label: 'List', icon: '•' }, + { type: 'image', label: 'Image', icon: '🖼' }, { type: 'divider', label: 'Divider', icon: '—' }, ]; }