suna/backend/sandbox/docker/visual_html_editor_router.py

1355 lines
52 KiB
Python

#!/usr/bin/env python3
"""
Visual HTML Editor Router
Provides visual HTML editing endpoints as a FastAPI router that can be included in other applications.
"""
import os
import re
from typing import Optional, Dict, Any
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from bs4 import BeautifulSoup, NavigableString
# Create router
router = APIRouter(prefix="/api/html", tags=["visual-editor"])
# Use /workspace as the default workspace directory
workspace_dir = "/workspace"
# All text elements that should be editable
TEXT_ELEMENTS = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', # Headings
'p', # Paragraphs
'span', 'strong', 'em', 'b', 'i', 'u', # Inline text formatting
'small', 'mark', 'del', 'ins', 'sub', 'sup', # Text modifications
'code', 'kbd', 'samp', 'var', 'pre', # Code and preformatted text
'blockquote', 'cite', 'q', # Quotes and citations
'abbr', 'dfn', 'time', 'data', # Semantic text
'address', 'figcaption', 'caption', # Descriptive text
'th', 'td', # Table cells
'dt', 'dd', # Definition lists
'li', # List items
'label', 'legend', # Form text
]
class EditTextRequest(BaseModel):
file_path: str
element_selector: str # CSS selector to identify element
new_text: str
class DeleteElementRequest(BaseModel):
file_path: str
element_selector: str
class SaveContentRequest(BaseModel):
file_path: str
html_content: str
class GetEditableElementsResponse(BaseModel):
elements: list[Dict[str, Any]]
@router.get("/{file_path:path}/editable-elements")
async def get_editable_elements(file_path: str):
"""Get all editable text elements from an HTML file"""
try:
full_path = os.path.join(workspace_dir, file_path)
if not os.path.exists(full_path):
raise HTTPException(status_code=404, detail="File not found")
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
soup = BeautifulSoup(content, 'html.parser')
elements = []
editable_counter = 0
# Find all elements that could contain text
all_elements = soup.find_all(TEXT_ELEMENTS + ['div'])
for element in all_elements:
# Strategy 1: Elements with ONLY text content (no child elements)
if element.string and element.string.strip():
element_id = f"editable-{editable_counter}"
element['data-editable-id'] = element_id
element['class'] = element.get('class', []) + ['editable-element']
elements.append({
'id': element_id,
'tag': element.name,
'text': element.string.strip(),
'selector': f'[data-editable-id="{element_id}"]',
'innerHTML': element.string.strip()
})
editable_counter += 1
# Strategy 2: Elements with mixed content - wrap raw text nodes individually
elif element.contents:
has_mixed_content = False
# Process each child node
for child in list(element.contents): # Use list() to avoid modification during iteration
# Check if it's a NavigableString (raw text) with actual content
if (isinstance(child, NavigableString) and child.strip()):
# This is a raw text node with content
text_content = child.strip()
if text_content:
# Create a wrapper span for the raw text
wrapper_span = soup.new_tag('span')
wrapper_span['data-editable-id'] = f"editable-{editable_counter}"
wrapper_span['class'] = 'editable-element raw-text-wrapper'
wrapper_span.string = text_content
# Replace the text node with the wrapped span
child.replace_with(wrapper_span)
elements.append({
'id': f"editable-{editable_counter}",
'tag': 'text-node',
'text': text_content,
'selector': f'[data-editable-id="editable-{editable_counter}"]',
'innerHTML': text_content
})
editable_counter += 1
has_mixed_content = True
# If this element has no mixed content but has text, make the whole element editable
if not has_mixed_content and element.get_text(strip=True):
element_id = f"editable-{editable_counter}"
element['data-editable-id'] = element_id
element['class'] = element.get('class', []) + ['editable-element']
elements.append({
'id': element_id,
'tag': element.name,
'text': element.get_text(strip=True),
'selector': f'[data-editable-id="{element_id}"]',
'innerHTML': str(element.decode_contents()) if element.contents else element.get_text(strip=True)
})
editable_counter += 1
return {"elements": elements}
except Exception as e:
print(f"Error getting editable elements: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/edit-text")
async def edit_text(request: EditTextRequest):
"""Edit text content of an element in an HTML file"""
try:
full_path = os.path.join(workspace_dir, request.file_path)
if not os.path.exists(full_path):
raise HTTPException(status_code=404, detail="File not found")
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
soup = BeautifulSoup(content, 'html.parser')
# Extract element ID from selector
element_id = request.element_selector.replace('[data-editable-id="', '').replace('"]', '')
# Find the specific editable element by its data-editable-id
target_element = soup.find(attrs={'data-editable-id': element_id})
if not target_element:
raise HTTPException(status_code=404, detail=f"Element with ID {element_id} not found")
print(f"🎯 Found element: {target_element.name} with ID {element_id} - '{target_element.get_text()[:50]}...'")
# Simple replacement - whether it's a regular element or a wrapped text node
if target_element.string:
target_element.string.replace_with(request.new_text)
else:
# Clear content and add new text
target_element.clear()
target_element.string = request.new_text
# Write back to file
with open(full_path, 'w', encoding='utf-8') as f:
f.write(str(soup))
print(f"✅ Successfully updated text in {request.file_path}: '{request.new_text}'")
return {"success": True, "message": "Text updated successfully"}
except Exception as e:
print(f"❌ Error editing text: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/delete-element")
async def delete_element(request: DeleteElementRequest):
"""Delete an element from an HTML file"""
try:
full_path = os.path.join(workspace_dir, request.file_path)
if not os.path.exists(full_path):
raise HTTPException(status_code=404, detail="File not found")
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
soup = BeautifulSoup(content, 'html.parser')
# Handle both editable elements and removable divs
if '[data-editable-id="' in request.element_selector:
# Text element deletion
element_id = request.element_selector.replace('[data-editable-id="', '').replace('"]', '')
# Find the specific editable element by its data-editable-id
target_element = soup.find(attrs={'data-editable-id': element_id})
if not target_element:
raise HTTPException(status_code=404, detail=f"Element with ID {element_id} not found")
elif '[data-removable-id="' in request.element_selector:
# Div removal
element_id = request.element_selector.replace('[data-removable-id="', '').replace('"]', '')
# Find the specific removable element by its data-removable-id
target_element = soup.find(attrs={'data-removable-id': element_id})
if not target_element:
raise HTTPException(status_code=404, detail=f"Element with ID {element_id} not found")
else:
raise HTTPException(status_code=400, detail="Invalid element selector")
print(f"🗑️ Deleting element: {target_element.name} - '{target_element.get_text()[:50]}...'")
# Remove element
target_element.decompose()
# Write back to file
with open(full_path, 'w', encoding='utf-8') as f:
f.write(str(soup))
print(f"🗑️ Successfully deleted element from {request.file_path}")
return {"success": True, "message": "Element deleted successfully"}
except Exception as e:
print(f"❌ Error deleting element: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/save-content")
async def save_content(request: SaveContentRequest):
"""Save the entire HTML content to file"""
try:
full_path = os.path.join(workspace_dir, request.file_path)
if not os.path.exists(full_path):
raise HTTPException(status_code=404, detail="File not found")
# Clean up the HTML content by removing editor-specific classes and attributes
soup = BeautifulSoup(request.html_content, 'html.parser')
# Remove editor-specific elements and attributes
for element in soup.find_all():
# Remove editor classes
if element.get('class'):
classes = element['class']
classes = [cls for cls in classes if cls not in ['editable-element', 'removable-element', 'raw-text-wrapper', 'selected', 'editing', 'element-modified', 'element-deleted']]
if classes:
element['class'] = classes
else:
del element['class']
# Remove editor data attributes
if element.get('data-editable-id'):
del element['data-editable-id']
if element.get('data-removable-id'):
del element['data-removable-id']
if element.get('data-original-text'):
del element['data-original-text']
# Remove editor controls
for control in soup.find_all(['div'], class_=['edit-controls', 'remove-controls']):
control.decompose()
# Remove editor header
for header in soup.find_all(['div'], class_='editor-header'):
header.decompose()
# Remove editor CSS and JS
for style in soup.find_all('style'):
if 'Visual Editor Styles' in style.get_text():
style.decompose()
for script in soup.find_all('script'):
if 'VisualHtmlEditor' in script.get_text():
script.decompose()
# Write the cleaned HTML back to file
with open(full_path, 'w', encoding='utf-8') as f:
f.write(str(soup))
print(f"💾 Successfully saved content to {request.file_path}")
return {"success": True, "message": "Content saved successfully"}
except Exception as e:
print(f"❌ Error saving content: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{file_path:path}/editor")
async def get_html_editor(file_path: str):
"""Serve the visual editor for an HTML file"""
try:
full_path = os.path.join(workspace_dir, file_path)
if not os.path.exists(full_path):
raise HTTPException(status_code=404, detail="File not found")
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
# Inject editor functionality into the HTML
editor_html = inject_editor_functionality(content, file_path)
return HTMLResponse(content=editor_html)
except Exception as e:
print(f"❌ Error serving editor: {e}")
raise HTTPException(status_code=500, detail=str(e))
def inject_editor_functionality(html_content: str, file_path: str) -> str:
"""Inject visual editor functionality into existing HTML"""
# Parse the HTML
soup = BeautifulSoup(html_content, 'html.parser')
# Apply the same transformation as the API endpoint
editable_counter = 0
# Find all elements that could contain text
all_elements = soup.find_all(TEXT_ELEMENTS + ['div'])
for element in all_elements:
# Strategy 1: Elements with ONLY text content (no child elements)
if element.string and element.string.strip():
element['data-editable-id'] = f"editable-{editable_counter}"
element['class'] = element.get('class', []) + ['editable-element']
editable_counter += 1
# Strategy 2: Elements with mixed content - wrap raw text nodes individually
elif element.contents:
has_mixed_content = False
# Process each child node
for child in list(element.contents): # Use list() to avoid modification during iteration
# Check if it's a NavigableString (raw text) with actual content
if (isinstance(child, NavigableString) and child.strip()):
# This is a raw text node with content
text_content = child.strip()
if text_content:
# Create a wrapper span for the raw text
wrapper_span = soup.new_tag('span')
wrapper_span['data-editable-id'] = f"editable-{editable_counter}"
wrapper_span['class'] = 'editable-element raw-text-wrapper'
wrapper_span.string = text_content
# Replace the text node with the wrapped span
child.replace_with(wrapper_span)
editable_counter += 1
has_mixed_content = True
# If this element has no mixed content but has text, make the whole element editable
if not has_mixed_content and element.get_text(strip=True):
element['data-editable-id'] = f"editable-{editable_counter}"
element['class'] = element.get('class', []) + ['editable-element']
editable_counter += 1
# All divs are removable (regardless of text content)
div_elements = soup.find_all('div')
for i, element in enumerate(div_elements):
element['data-removable-id'] = f'div-{i}'
element['class'] = element.get('class', []) + ['removable-element']
# Add editor CSS
editor_css = """
<style>
/* Visual Editor Styles - Clean Black/White Theme */
.editable-element, .removable-element {
position: relative;
transition: all 0.15s ease;
}
/* Style for wrapped raw text nodes - make them completely invisible */
.raw-text-wrapper {
display: inline;
background: transparent;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
text-decoration: inherit;
font-weight: inherit;
font-style: inherit;
font-size: inherit;
line-height: inherit;
letter-spacing: inherit;
text-transform: inherit;
}
.editable-element {
cursor: pointer;
transition: outline 0.15s ease;
}
.removable-element {
cursor: pointer;
transition: outline 0.15s ease;
}
/* Only show visual feedback on selection, not hover */
.editable-element.selected {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.removable-element.selected {
outline: 2px solid #f97316;
outline-offset: 2px;
}
.editable-element.editing {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.element-modified {
outline: 2px dashed #f59e0b !important;
outline-offset: 2px;
}
.element-deleted {
opacity: 0.4;
outline: 2px dashed #ef4444 !important;
outline-offset: 2px;
}
.edit-controls {
position: absolute;
top: -45px;
right: -5px;
display: none;
z-index: 1000;
background: white;
border: 1px solid #e4e4e7;
padding: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-radius: 8px;
transition: opacity 0.15s ease;
}
.editable-element.selected .edit-controls {
display: flex !important;
gap: 2px;
}
.removable-element.selected .remove-controls {
display: flex !important;
gap: 2px;
}
.remove-controls {
position: absolute;
top: -45px;
right: -5px;
display: none;
z-index: 1000;
background: white;
border: 1px solid #e4e4e7;
padding: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-radius: 8px;
transition: opacity 0.15s ease;
}
.edit-btn, .delete-btn {
background: white;
border: 1px solid #e4e4e7;
cursor: pointer;
padding: 6px 8px;
font-size: 12px;
color: #09090b;
transition: all 0.15s ease;
min-width: 32px;
text-align: center;
border-radius: 6px;
font-weight: 500;
}
.edit-btn:hover {
background: #f4f4f5;
border-color: #d4d4d8;
}
.delete-btn:hover {
background: #fef2f2;
border-color: #fca5a5;
color: #dc2626;
}
.editor-input {
min-width: 200px;
padding: 8px 12px;
border: 1px solid #e4e4e7;
border-radius: 6px;
font-family: inherit;
font-size: inherit;
background: white;
color: #09090b;
outline: none;
transition: all 0.15s ease;
}
.editor-input:focus {
border-color: #09090b;
box-shadow: 0 0 0 2px rgba(9, 9, 11, 0.1);
}
.save-cancel-controls {
position: absolute;
top: 100%;
left: 0;
background: white;
border: 1px solid #e4e4e7;
border-radius: 8px;
padding: 4px;
margin-top: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
display: flex;
gap: 4px;
z-index: 1000;
}
.save-btn, .cancel-btn {
padding: 6px 12px;
border: 1px solid #e4e4e7;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
background: white;
color: #09090b;
transition: all 0.15s ease;
font-weight: 500;
}
.save-btn {
background: #09090b;
color: white;
border-color: #09090b;
}
.save-btn:hover {
background: #18181b;
border-color: #18181b;
}
.cancel-btn:hover {
background: #f4f4f5;
border-color: #d4d4d8;
}
.editor-notification {
position: fixed;
top: 80px;
right: 20px;
padding: 12px 16px;
background: white;
border: 1px solid #e4e4e7;
color: #09090b;
font-weight: 500;
z-index: 10000;
animation: slideIn 0.3s ease;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
font-size: 13px;
border-radius: 8px;
}
.editor-notification.success:before {
content: "";
color: #09090b;
}
.editor-notification.error {
background: #09090b;
color: white;
border-color: #09090b;
}
.editor-notification.error:before {
content: "";
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes flash {
0%, 100% { background-color: transparent; }
50% { background-color: rgba(59, 130, 246, 0.2); }
}
/* Editor header */
.editor-header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: white;
color: #09090b;
padding: 12px 20px;
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
border-bottom: 1px solid #e4e4e7;
display: flex;
justify-content: center;
align-items: center;
}
.editor-actions {
display: flex;
gap: 12px;
align-items: center;
}
.nav-controls {
display: flex;
gap: 4px;
margin-right: 12px;
}
.nav-btn {
padding: 6px 12px;
border: 1px solid #e4e4e7;
background: white;
color: #09090b;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border-radius: 6px;
}
.nav-btn:hover:not(:disabled) {
background: #f4f4f5;
border-color: #d4d4d8;
}
.nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.editor-status {
font-size: 13px;
color: #71717a;
font-weight: 500;
margin: 0 8px;
}
.header-btn {
padding: 6px 16px;
border: 1px solid #e4e4e7;
background: white;
color: #09090b;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border-radius: 6px;
}
.header-btn:hover:not(:disabled) {
background: #f4f4f5;
border-color: #d4d4d8;
}
.header-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.save-btn-header {
background: #09090b;
color: white;
border-color: #09090b;
}
.save-btn-header:hover:not(:disabled) {
background: #18181b;
border-color: #18181b;
}
body {
padding-top: 70px !important;
}
</style>
"""
# Add editor JavaScript
editor_js = f"""
<script>
const API_BASE = '/api/html';
const FILE_PATH = '{file_path}';
class VisualHtmlEditor {{
constructor() {{
this.currentlyEditing = null;
this.pendingChanges = new Map(); // Store changes before saving
this.deletedElements = new Set(); // Track deleted elements
this.originalContent = new Map(); // Store original content for revert
this.selectedElement = null; // Currently selected element
this.changeOrder = []; // Array to track order of changes (for undo)
this.undoneChanges = []; // Array to track undone changes (for redo)
this.init();
this.setupBeforeUnload();
}}
init() {{
this.addEditorHeader();
this.addEditControls();
this.bindEvents();
console.log('🎨 Visual HTML Editor initialized for:', FILE_PATH);
}}
addEditorHeader() {{
const header = document.createElement('div');
header.className = 'editor-header';
header.innerHTML = `
<div class="editor-actions">
<div class="nav-controls">
<button class="nav-btn" id="undo-change" disabled title="Undo last change">← Undo</button>
<button class="nav-btn" id="redo-change" disabled title="Redo last undone change">Redo →</button>
</div>
<span class="editor-status" id="editor-status">No changes</span>
<button class="header-btn" id="revert-btn" disabled>Revert All</button>
<button class="header-btn save-btn-header" id="save-btn" disabled>Save All</button>
</div>
`;
document.body.insertBefore(header, document.body.firstChild);
}}
addEditControls() {{
// Store original text for editable elements
document.querySelectorAll('.editable-element').forEach(element => {{
if (!element.dataset.originalText) {{
// For mixed content, try to get direct text nodes only
const directTextNodes = Array.from(element.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE)
.map(node => node.textContent.trim())
.filter(text => text.length > 0);
if (directTextNodes.length > 0) {{
element.dataset.originalText = directTextNodes.join(' ');
}} else {{
// Fallback to all text content
element.dataset.originalText = element.textContent.trim();
}}
}}
}});
// Note: Controls are now created dynamically on click, not pre-added
}}
createEditControls(element) {{
// Remove any existing controls first
this.removeAllControls();
const controls = document.createElement('div');
controls.className = 'edit-controls';
const editBtn = document.createElement('button');
editBtn.className = 'edit-btn';
editBtn.innerHTML = '✏️';
editBtn.title = 'Edit text';
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-btn';
deleteBtn.innerHTML = '🗑️';
deleteBtn.title = 'Delete element';
controls.appendChild(editBtn);
controls.appendChild(deleteBtn);
element.appendChild(controls);
return controls;
}}
createRemoveControls(element) {{
// Remove any existing controls first
this.removeAllControls();
const controls = document.createElement('div');
controls.className = 'remove-controls';
const removeBtn = document.createElement('button');
removeBtn.className = 'delete-btn';
removeBtn.innerHTML = '🗑️';
removeBtn.title = 'Remove this div';
controls.appendChild(removeBtn);
element.appendChild(controls);
return controls;
}}
removeAllControls() {{
// Remove all existing control elements
document.querySelectorAll('.edit-controls, .remove-controls').forEach(control => {{
control.remove();
}});
}}
bindEvents() {{
document.addEventListener('click', (e) => {{
if (e.target.classList.contains('edit-btn')) {{
e.stopPropagation();
this.startEditing(e.target.closest('.editable-element'));
}} else if (e.target.classList.contains('delete-btn')) {{
e.stopPropagation();
const element = e.target.closest('.editable-element') || e.target.closest('.removable-element');
this.deleteElement(element);
}} else if (e.target.classList.contains('save-btn')) {{
e.stopPropagation();
this.saveEdit();
}} else if (e.target.classList.contains('cancel-btn')) {{
e.stopPropagation();
this.cancelEdit();
}} else if (e.target.id === 'save-btn') {{
e.stopPropagation();
this.saveAllChanges();
}} else if (e.target.id === 'revert-btn') {{
e.stopPropagation();
this.revertAllChanges();
}} else if (e.target.id === 'undo-change') {{
e.stopPropagation();
this.undoLastChange();
}} else if (e.target.id === 'redo-change') {{
e.stopPropagation();
this.redoLastChange();
}} else if (e.target.closest('.editable-element')) {{
e.stopPropagation();
this.selectElement(e.target.closest('.editable-element'));
}} else if (e.target.closest('.removable-element')) {{
e.stopPropagation();
this.selectElement(e.target.closest('.removable-element'));
}} else {{
// Clicking outside elements deselects
this.clearSelection();
if (this.currentlyEditing) {{
this.cancelEdit();
}}
}}
}});
document.addEventListener('keydown', (e) => {{
if (this.currentlyEditing) {{
if (e.key === 'Enter' && e.ctrlKey) {{
this.saveEdit();
}} else if (e.key === 'Escape') {{
this.cancelEdit();
}}
}} else if (e.ctrlKey && e.key === 's') {{
e.preventDefault();
this.saveAllChanges();
}}
}});
}}
selectElement(element) {{
// Clear previous selection
this.clearSelection();
// Set new selection
this.selectedElement = element;
element.classList.add('selected');
// Create appropriate controls based on element type
if (element.classList.contains('editable-element')) {{
this.createEditControls(element);
}} else if (element.classList.contains('removable-element')) {{
this.createRemoveControls(element);
}}
console.log('🎯 Selected element:', element.dataset.editableId || element.dataset.removableId);
}}
clearSelection() {{
if (this.selectedElement) {{
this.selectedElement.classList.remove('selected');
this.selectedElement = null;
}}
// Remove all controls when clearing selection
this.removeAllControls();
}}
undoLastChange() {{
if (this.changeOrder.length === 0) return;
// Get the most recent change (last in array)
const elementId = this.changeOrder[this.changeOrder.length - 1];
const change = this.pendingChanges.get(elementId);
if (!change) return;
// Log what we're undoing
const changeType = change.type === 'edit' ? 'text edit' : 'deletion';
console.log(`↩️ Undoing ${{changeType}} for element:`, elementId);
// Move to undo stack before reverting
this.undoneChanges.push({{
elementId: elementId,
change: change,
originalContent: this.originalContent.get(elementId)
}});
// Revert the change
this.revertSingleChange(elementId);
// Update UI
this.updateStatus();
this.updateUndoRedoButtons();
this.showNotification('Change undone', 'success');
}}
redoLastChange() {{
if (this.undoneChanges.length === 0) return;
// Get the most recently undone change
const undoneItem = this.undoneChanges.pop();
const {{ elementId, change, originalContent }} = undoneItem;
// Log what we're redoing
const changeType = change.type === 'edit' ? 'text edit' : 'deletion';
console.log(`🔄 Redoing ${{changeType}} for element:`, elementId);
// Restore the change
if (change.type === 'edit') {{
change.element.textContent = change.newText;
change.element.dataset.originalText = change.newText;
change.element.classList.add('element-modified');
}} else if (change.type === 'delete') {{
change.element.classList.add('element-deleted');
this.deletedElements.add(elementId);
}}
// Restore to tracking
this.pendingChanges.set(elementId, change);
this.originalContent.set(elementId, originalContent);
this.changeOrder.push(elementId);
// Scroll to and highlight
change.element.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
this.selectElement(change.element);
// Flash animation
change.element.style.animation = 'none';
setTimeout(() => {{
change.element.style.animation = 'flash 0.6s ease-out';
}}, 10);
// Update UI
this.updateStatus();
this.updateUndoRedoButtons();
this.showNotification('Change redone', 'success');
}}
revertSingleChange(elementId) {{
const change = this.pendingChanges.get(elementId);
if (!change) return;
if (change.type === 'edit') {{
// Revert text edit
const originalContent = this.originalContent.get(elementId);
if (originalContent) {{
change.element.textContent = originalContent;
change.element.dataset.originalText = originalContent;
}}
change.element.classList.remove('element-modified');
// Scroll to and highlight the reverted element
change.element.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
this.selectElement(change.element);
}} else if (change.type === 'delete') {{
// Revert deletion
change.element.classList.remove('element-deleted');
this.deletedElements.delete(elementId);
// Scroll to and highlight the restored element
change.element.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
this.selectElement(change.element);
}}
// Remove from tracking
this.pendingChanges.delete(elementId);
this.originalContent.delete(elementId);
// Remove from change order
const index = this.changeOrder.indexOf(elementId);
if (index > -1) {{
this.changeOrder.splice(index, 1);
}}
// Flash animation to show revert
change.element.style.animation = 'none';
setTimeout(() => {{
change.element.style.animation = 'flash 0.6s ease-out';
}}, 10);
this.showNotification('Change reverted', 'success');
}}
updateUndoRedoButtons() {{
const undoBtn = document.getElementById('undo-change');
const redoBtn = document.getElementById('redo-change');
undoBtn.disabled = this.changeOrder.length === 0;
redoBtn.disabled = this.undoneChanges.length === 0;
}}
setupBeforeUnload() {{
window.addEventListener('beforeunload', (e) => {{
if (this.pendingChanges.size > 0) {{
const message = 'You have unsaved changes. Are you sure you want to leave?';
e.preventDefault();
e.returnValue = message;
return message;
}}
}});
}}
startEditing(element) {{
if (this.currentlyEditing) {{
this.cancelEdit();
}}
this.currentlyEditing = element;
element.classList.add('editing');
const currentText = element.dataset.originalText || element.textContent;
const input = document.createElement('input');
input.type = 'text';
input.className = 'editor-input';
input.value = currentText;
input.style.width = Math.max(200, element.offsetWidth) + 'px';
const controls = document.createElement('div');
controls.className = 'save-cancel-controls';
const saveBtn = document.createElement('button');
saveBtn.className = 'save-btn';
saveBtn.textContent = 'Save';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'cancel-btn';
cancelBtn.textContent = 'Cancel';
controls.appendChild(saveBtn);
controls.appendChild(cancelBtn);
element.style.position = 'relative';
element.textContent = '';
element.appendChild(input);
element.appendChild(controls);
input.focus();
input.select();
this.originalText = currentText;
console.log('📝 Started editing element:', element.dataset.editableId);
}}
saveEdit() {{
if (!this.currentlyEditing) return;
const input = this.currentlyEditing.querySelector('.editor-input');
const newText = input.value.trim();
if (!newText) {{
this.showNotification('Text cannot be empty', 'error');
return;
}}
const elementId = this.currentlyEditing.dataset.editableId;
// Store original content if not already stored
if (!this.originalContent.has(elementId)) {{
this.originalContent.set(elementId, this.originalText);
}}
// Track the pending change
this.pendingChanges.set(elementId, {{
type: 'edit',
element: this.currentlyEditing,
oldText: this.originalText,
newText: newText,
selector: `[data-editable-id="${{elementId}}"]`
}});
// Track change order for navigation
if (!this.changeOrder.includes(elementId)) {{
this.changeOrder.push(elementId);
}}
// Clear redo stack when new change is made
this.undoneChanges = [];
// Update the visual content
this.currentlyEditing.textContent = newText;
this.currentlyEditing.dataset.originalText = newText;
this.currentlyEditing.classList.add('element-modified');
console.log('📝 Change tracked locally:', elementId, newText);
this.updateStatus();
this.updateUndoRedoButtons();
this.finishEditing();
}}
cancelEdit() {{
if (!this.currentlyEditing) return;
console.log('❌ Cancelled editing');
this.currentlyEditing.textContent = this.originalText;
this.finishEditing();
}}
finishEditing() {{
if (this.currentlyEditing) {{
this.currentlyEditing.classList.remove('editing');
// If this element is still selected, recreate its controls
if (this.selectedElement === this.currentlyEditing) {{
setTimeout(() => {{
if (this.selectedElement && this.selectedElement.classList.contains('editable-element')) {{
this.createEditControls(this.selectedElement);
}}
}}, 10);
}}
this.currentlyEditing = null;
this.originalText = null;
}}
}}
deleteElement(element) {{
const text = element.textContent.substring(0, 60);
if (!confirm('Delete this element?\\\\n\\\\n"' + text + '..."')) {{
return;
}}
const elementId = element.dataset.editableId || element.dataset.removableId;
const isRemovable = element.classList.contains('removable-element');
// Store original content if not already stored
if (!this.originalContent.has(elementId)) {{
this.originalContent.set(elementId, {{
element: element.cloneNode(true),
parent: element.parentNode,
nextSibling: element.nextSibling
}});
}}
// Track the deletion
this.pendingChanges.set(elementId, {{
type: 'delete',
element: element,
selector: isRemovable ? `[data-removable-id="${{elementId}}"]` : `[data-editable-id="${{elementId}}"]`
}});
// Track change order for navigation
if (!this.changeOrder.includes(elementId)) {{
this.changeOrder.push(elementId);
}}
// Clear redo stack when new change is made
this.undoneChanges = [];
// Visual indication of deletion
element.classList.add('element-deleted');
this.deletedElements.add(elementId);
console.log('🗑️ Element marked for deletion:', elementId);
this.updateStatus();
this.updateUndoRedoButtons();
}}
updateStatus() {{
const statusEl = document.getElementById('editor-status');
const saveBtn = document.getElementById('save-btn');
const revertBtn = document.getElementById('revert-btn');
const changeCount = this.pendingChanges.size;
if (changeCount === 0) {{
statusEl.textContent = 'No changes';
saveBtn.disabled = true;
revertBtn.disabled = true;
}} else {{
statusEl.textContent = `${{changeCount}} unsaved change${{changeCount === 1 ? '' : 's'}}`;
saveBtn.disabled = false;
revertBtn.disabled = false;
}}
this.updateUndoRedoButtons();
}}
async saveAllChanges() {{
if (this.pendingChanges.size === 0) return;
// Confirm before saving (permanent action)
const changeCount = this.pendingChanges.size;
if (!confirm(`Save all ${{changeCount}} change${{changeCount === 1 ? '' : 's'}} to file?\\\\n\\\\nThis action cannot be undone.`)) {{
return;
}}
const saveBtn = document.getElementById('save-btn');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {{
// IMPORTANT: Actually remove elements marked for deletion from DOM before saving
console.log('🗑️ Processing deletions before save...');
for (const [elementId, change] of this.pendingChanges) {{
if (change.type === 'delete') {{
console.log(`🗑️ Removing element ${{elementId}} from DOM`);
// Actually remove the element from the DOM
if (change.element && change.element.parentNode) {{
change.element.parentNode.removeChild(change.element);
}}
}}
}}
// Get the current HTML content from the DOM (now without deleted elements)
const currentHtml = document.documentElement.outerHTML;
// Send it to a new endpoint that replaces the file content
const response = await fetch(`${{API_BASE}}/save-content`, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{
file_path: FILE_PATH,
html_content: currentHtml
}})
}});
if (!response.ok) {{
const error = await response.text();
throw new Error(`Failed to save: ${{error}}`);
}}
// Clear all tracking
this.pendingChanges.clear();
this.deletedElements.clear();
this.originalContent.clear();
this.changeOrder = [];
this.undoneChanges = [];
// Remove visual indicators
document.querySelectorAll('.element-modified, .element-deleted').forEach(el => {{
el.classList.remove('element-modified', 'element-deleted');
}});
this.showNotification('All changes saved', 'success');
console.log('✅ All changes saved to server');
}} catch (error) {{
this.showNotification('Failed to save: ' + error.message, 'error');
console.error('❌ Error saving changes:', error);
}} finally {{
saveBtn.disabled = false;
saveBtn.textContent = 'Save All';
this.updateStatus();
}}
}}
revertAllChanges() {{
if (!confirm('Revert all unsaved changes?')) return;
// Revert all changes
for (const [elementId, change] of this.pendingChanges) {{
if (change.type === 'edit') {{
const originalContent = this.originalContent.get(elementId);
if (originalContent) {{
change.element.textContent = originalContent;
change.element.dataset.originalText = originalContent;
}}
change.element.classList.remove('element-modified');
}} else if (change.type === 'delete') {{
change.element.classList.remove('element-deleted');
this.deletedElements.delete(elementId);
}}
}}
// Clear all tracking
this.pendingChanges.clear();
this.originalContent.clear();
this.changeOrder = [];
this.undoneChanges = [];
// Clear localStorage
localStorage.removeItem(`editor_changes_${{FILE_PATH}}`);
this.showNotification('All changes reverted', 'success');
console.log('↩️ All changes reverted');
this.updateStatus();
}}
showNotification(message, type) {{
const notification = document.createElement('div');
notification.className = `editor-notification ${{type}}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {{
notification.remove();
}}, 3000);
}}
}}
// Initialize editor when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {{
new VisualHtmlEditor();
}});
</script>
"""
# Inject CSS and JS
if soup.head:
soup.head.append(BeautifulSoup(editor_css, 'html.parser'))
if soup.body:
soup.body.append(BeautifulSoup(editor_js, 'html.parser'))
return str(soup)