feat: rebuild advanced image editor with full drag-n-drop functionality
Completely rebuilt the image editor to match the sophistication of the original implementation before the modular refactoring. Now includes: ADVANCED FEATURES RESTORED: - 🎯 Drag & drop image upload with visual feedback - 📁 Click-to-select file functionality - 🖼️ Live image preview with overlay effects - ✏️ Dedicated alt text editing interface - ⚠️ Change tracking and unsaved changes indicator - 🔄 Staging system for managing edits before commit - 🎨 Professional UI with hover states and transitions TECHNICAL IMPLEMENTATION: - FileReader API for local image handling - Comprehensive drag event management (dragover, dragleave, drop) - Staging state system tracks original vs modified content - Visual feedback for drag operations (border color changes) - Input validation and file type checking - Reset functionality preserves original state - Change detection for both image and alt text modifications USER EXPERIENCE: - Intuitive drag-and-drop interface - Real-time preview of changes - Clear change indicators - Three-button workflow (Accept/Cancel/Reset) - Responsive design adapting to content The image editing experience now provides the full professional-grade functionality that was present in the original monolithic implementation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -396,47 +396,391 @@ class DOMRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show editor for image sections
|
* Show advanced image editor with drag & drop, file upload, and preview
|
||||||
*/
|
*/
|
||||||
showImageEditor(sectionId, section) {
|
showImageEditor(sectionId, section) {
|
||||||
debug('showImageEditor: called for image section: ' + sectionId, 'EDITOR');
|
debug('showImageEditor: called for image section: ' + sectionId, 'EDITOR');
|
||||||
|
|
||||||
|
// Track staging state for this editor
|
||||||
|
const stagingState = {
|
||||||
|
originalMarkdown: section.currentMarkdown,
|
||||||
|
currentAltText: '',
|
||||||
|
currentImageSrc: '',
|
||||||
|
stagedImageSrc: null,
|
||||||
|
stagedAltText: null,
|
||||||
|
hasChanges: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse markdown to extract image info
|
||||||
|
const imageMatch = section.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
|
||||||
|
if (imageMatch) {
|
||||||
|
const [, altText, imageSrc] = imageMatch;
|
||||||
|
stagingState.currentAltText = altText;
|
||||||
|
stagingState.currentImageSrc = imageSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create image editor content area
|
||||||
const editorContent = document.createElement('div');
|
const editorContent = document.createElement('div');
|
||||||
editorContent.innerHTML = `
|
editorContent.className = 'ui-edit-image-content';
|
||||||
<div style="margin-bottom: 12px;">
|
editorContent.style.cssText = `
|
||||||
<strong>Image Editor</strong><br>
|
display: flex;
|
||||||
Edit the markdown for this image:
|
flex-direction: column;
|
||||||
</div>
|
gap: 15px;
|
||||||
<textarea style="width: 100%; height: 80px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">${section.currentMarkdown}</textarea>
|
flex: 1;
|
||||||
<div style="margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end;">
|
min-width: 0;
|
||||||
<button id="accept-image" style="background: #28a745; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">Accept</button>
|
|
||||||
<button id="cancel-image" style="background: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">Cancel</button>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Image preview with drop zone
|
||||||
|
const imagePreview = document.createElement('div');
|
||||||
|
imagePreview.className = 'ui-edit-image-preview';
|
||||||
|
imagePreview.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px dashed #007bff;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Function to update image preview
|
||||||
|
const updateImagePreview = (imageSrc, altText) => {
|
||||||
|
imagePreview.innerHTML = '';
|
||||||
|
|
||||||
|
if (imageSrc) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = imageSrc;
|
||||||
|
img.alt = altText || '';
|
||||||
|
img.style.cssText = `
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 150px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
`;
|
||||||
|
imagePreview.appendChild(img);
|
||||||
|
|
||||||
|
// Add overlay for drop zone
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'drop-overlay';
|
||||||
|
overlay.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 123, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
`;
|
||||||
|
overlay.textContent = '📁 Drop new image here';
|
||||||
|
imagePreview.appendChild(overlay);
|
||||||
|
} else {
|
||||||
|
// Show drop zone placeholder
|
||||||
|
const placeholder = document.createElement('div');
|
||||||
|
placeholder.style.cssText = `
|
||||||
|
text-align: center;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
placeholder.innerHTML = `
|
||||||
|
<div style="font-size: 48px; margin-bottom: 12px;">📁</div>
|
||||||
|
<div style="margin-bottom: 8px;"><strong>Drop image here or click to select</strong></div>
|
||||||
|
<div style="font-size: 12px;">Supports JPG, PNG, GIF, WebP</div>
|
||||||
|
`;
|
||||||
|
imagePreview.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize preview
|
||||||
|
updateImagePreview(stagingState.currentImageSrc, stagingState.currentAltText);
|
||||||
|
|
||||||
|
// File input for image selection
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
|
||||||
|
// Function to handle image file selection
|
||||||
|
const handleImageFile = (file) => {
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
stagingState.stagedImageSrc = event.target.result;
|
||||||
|
stagingState.hasChanges = true;
|
||||||
|
updateImagePreview(stagingState.stagedImageSrc, altTextInput.value);
|
||||||
|
updateChangeIndicator();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag and drop functionality
|
||||||
|
imagePreview.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
imagePreview.style.borderColor = '#28a745';
|
||||||
|
imagePreview.style.backgroundColor = '#f8fff8';
|
||||||
|
const overlay = imagePreview.querySelector('.drop-overlay');
|
||||||
|
if (overlay) overlay.style.display = 'flex';
|
||||||
|
});
|
||||||
|
|
||||||
|
imagePreview.addEventListener('dragleave', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
imagePreview.style.borderColor = '#007bff';
|
||||||
|
imagePreview.style.backgroundColor = 'white';
|
||||||
|
const overlay = imagePreview.querySelector('.drop-overlay');
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
imagePreview.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
imagePreview.style.borderColor = '#007bff';
|
||||||
|
imagePreview.style.backgroundColor = 'white';
|
||||||
|
const overlay = imagePreview.querySelector('.drop-overlay');
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleImageFile(files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click to select file
|
||||||
|
imagePreview.addEventListener('click', () => {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
handleImageFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alt text editor
|
||||||
|
const altTextContainer = document.createElement('div');
|
||||||
|
altTextContainer.className = 'ui-edit-alt-text-container';
|
||||||
|
altTextContainer.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const altTextLabel = document.createElement('label');
|
||||||
|
altTextLabel.textContent = 'Alt Text Description:';
|
||||||
|
altTextLabel.style.cssText = `
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const altTextInput = document.createElement('input');
|
||||||
|
altTextInput.type = 'text';
|
||||||
|
altTextInput.value = stagingState.currentAltText;
|
||||||
|
altTextInput.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
`;
|
||||||
|
|
||||||
|
altTextInput.addEventListener('focus', () => {
|
||||||
|
altTextInput.style.borderColor = '#007bff';
|
||||||
|
});
|
||||||
|
|
||||||
|
altTextInput.addEventListener('blur', () => {
|
||||||
|
altTextInput.style.borderColor = '#ddd';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track alt text changes
|
||||||
|
altTextInput.addEventListener('input', () => {
|
||||||
|
stagingState.stagedAltText = altTextInput.value;
|
||||||
|
stagingState.hasChanges = altTextInput.value !== stagingState.currentAltText || stagingState.stagedImageSrc !== null;
|
||||||
|
updateChangeIndicator();
|
||||||
|
});
|
||||||
|
|
||||||
|
altTextContainer.appendChild(altTextLabel);
|
||||||
|
altTextContainer.appendChild(altTextInput);
|
||||||
|
|
||||||
|
// Change indicator
|
||||||
|
const changeIndicator = document.createElement('div');
|
||||||
|
changeIndicator.className = 'change-indicator';
|
||||||
|
changeIndicator.style.cssText = `
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
display: none;
|
||||||
|
font-weight: 500;
|
||||||
|
`;
|
||||||
|
changeIndicator.textContent = '⚠️ You have unsaved changes';
|
||||||
|
|
||||||
|
const updateChangeIndicator = () => {
|
||||||
|
if (stagingState.hasChanges) {
|
||||||
|
changeIndicator.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
changeIndicator.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assemble content
|
||||||
|
editorContent.appendChild(imagePreview);
|
||||||
|
editorContent.appendChild(altTextContainer);
|
||||||
|
editorContent.appendChild(changeIndicator);
|
||||||
|
editorContent.appendChild(fileInput);
|
||||||
|
|
||||||
|
// Create controls
|
||||||
|
const controls = document.createElement('div');
|
||||||
|
controls.className = 'ui-edit-controls';
|
||||||
|
controls.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const acceptBtn = document.createElement('button');
|
||||||
|
acceptBtn.textContent = '✓ Accept';
|
||||||
|
acceptBtn.style.cssText = `
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
background: #28a745;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.textContent = '✗ Cancel';
|
||||||
|
cancelBtn.style.cssText = `
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
background: #dc3545;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resetBtn = document.createElement('button');
|
||||||
|
resetBtn.textContent = '↺ Reset';
|
||||||
|
resetBtn.style.cssText = `
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
background: #fd7e14;
|
||||||
|
`;
|
||||||
|
|
||||||
|
controls.appendChild(acceptBtn);
|
||||||
|
controls.appendChild(cancelBtn);
|
||||||
|
controls.appendChild(resetBtn);
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
acceptBtn.addEventListener('click', () => {
|
||||||
|
// Apply staged changes only when accept is clicked
|
||||||
|
if (stagingState.hasChanges) {
|
||||||
|
let newMarkdown = stagingState.originalMarkdown;
|
||||||
|
|
||||||
|
// Apply image source change if staged
|
||||||
|
if (stagingState.stagedImageSrc !== null) {
|
||||||
|
const currentImageMatch = newMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
|
||||||
|
if (currentImageMatch) {
|
||||||
|
newMarkdown = newMarkdown.replace(
|
||||||
|
/!\[(.*?)\]\((.*?)\)/,
|
||||||
|
`![${currentImageMatch[1]}](${stagingState.stagedImageSrc})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply alt text change if staged
|
||||||
|
if (stagingState.stagedAltText !== null) {
|
||||||
|
newMarkdown = newMarkdown.replace(
|
||||||
|
/!\[(.*?)\]/,
|
||||||
|
`![${stagingState.stagedAltText}]`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update section with final changes
|
||||||
|
this.sectionManager.updateContent(sectionId, newMarkdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept changes and hide editor
|
||||||
|
this.sectionManager.acceptChanges(sectionId);
|
||||||
|
this.currentFloatingMenu.hide();
|
||||||
|
this.currentFloatingMenu = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
// Discard all staged changes and hide editor
|
||||||
|
this.sectionManager.cancelChanges(sectionId);
|
||||||
|
this.currentFloatingMenu.hide();
|
||||||
|
this.currentFloatingMenu = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
resetBtn.addEventListener('click', () => {
|
||||||
|
// Reset to original content
|
||||||
|
const originalImageMatch = stagingState.originalMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
|
||||||
|
if (originalImageMatch) {
|
||||||
|
const [, originalAltText, originalImageSrc] = originalImageMatch;
|
||||||
|
stagingState.currentAltText = originalAltText;
|
||||||
|
stagingState.currentImageSrc = originalImageSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any staged changes
|
||||||
|
stagingState.stagedImageSrc = null;
|
||||||
|
stagingState.stagedAltText = null;
|
||||||
|
stagingState.hasChanges = false;
|
||||||
|
|
||||||
|
// Reset alt text input to original
|
||||||
|
altTextInput.value = stagingState.currentAltText;
|
||||||
|
|
||||||
|
// Reset preview to original
|
||||||
|
updateImagePreview(stagingState.currentImageSrc, stagingState.currentAltText);
|
||||||
|
updateChangeIndicator();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create floating menu
|
||||||
const floatingMenu = new FloatingMenu(sectionId, 'image', this);
|
const floatingMenu = new FloatingMenu(sectionId, 'image', this);
|
||||||
this.currentFloatingMenu = floatingMenu;
|
this.currentFloatingMenu = floatingMenu;
|
||||||
this.editingSections.add(sectionId);
|
this.editingSections.add(sectionId);
|
||||||
|
|
||||||
floatingMenu.show(editorContent);
|
floatingMenu.show(editorContent, controls);
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
const textarea = editorContent.querySelector('textarea');
|
|
||||||
const acceptBtn = editorContent.querySelector('#accept-image');
|
|
||||||
const cancelBtn = editorContent.querySelector('#cancel-image');
|
|
||||||
|
|
||||||
acceptBtn.addEventListener('click', () => {
|
|
||||||
this.sectionManager.updateContent(sectionId, textarea.value);
|
|
||||||
this.sectionManager.acceptChanges(sectionId);
|
|
||||||
floatingMenu.hide();
|
|
||||||
this.currentFloatingMenu = null; // Clear reference
|
|
||||||
});
|
|
||||||
|
|
||||||
cancelBtn.addEventListener('click', () => {
|
|
||||||
this.sectionManager.cancelChanges(sectionId);
|
|
||||||
floatingMenu.hide();
|
|
||||||
this.currentFloatingMenu = null; // Clear reference
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user