implement image upload

This commit is contained in:
Juergen Kunz
2025-06-24 17:16:13 +00:00
parent 90fc8bed35
commit fca3638f7f
5 changed files with 321 additions and 13 deletions

View File

@ -533,6 +533,10 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
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<string> {
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);
}
}

View File

@ -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 `
<div class="block image${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}">
${isLoading ? `
<div class="image-loading">Uploading image...</div>
` : ''}
${imageUrl ? `
<div class="image-container">
<img src="${imageUrl}" alt="${this.block.content || 'Uploaded image'}" />
</div>
` : `
<div class="image-upload-placeholder">
<div class="upload-icon">🖼️</div>
<div class="upload-text">Click to upload an image</div>
<div class="upload-hint">or drag and drop</div>
</div>
<input type="file" accept="image/*" style="display: none;" />
`}
</div>
`;
}
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<void> {
// 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

View File

@ -37,6 +37,13 @@ export class WysiwygConverters {
return '';
case 'divider':
return '<hr>';
case 'image':
const imageUrl = block.metadata?.url;
if (imageUrl) {
const altText = this.escapeHtml(block.content || 'Image');
return `<img src="${imageUrl}" alt="${altText}" />`;
}
return '';
default:
return `<p>${content}</p>`;
}
@ -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)}`,

View File

@ -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;
}

View File

@ -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: '—' },
];
}