Files
insertr/demo-site/insertr/insertr.js
Joakim a13546aac2 Implement element-level editing with semantic field detection
Replace container-based blob editing approach with individual element editing. Each .insertr element now gets appropriate field type (text, textarea, link) based on HTML tag and classes. Provides much better UX with separate inputs for headlines, paragraphs, and buttons while preserving HTML structure and styling.
2025-08-30 17:01:25 +02:00

535 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Insertr - Element-Level Edit-in-place CMS Library
* Add class="insertr" to any element to make it editable
*/
class Insertr {
constructor(options = {}) {
this.options = {
apiEndpoint: options.apiEndpoint || '/api/content',
authEndpoint: options.authEndpoint || '/api/auth',
storageKey: 'insertr_content',
autoInit: options.autoInit !== false,
...options
};
this.state = {
isAuthenticated: false,
editMode: false,
currentUser: null,
contentCache: new Map(),
activeEditor: null
};
// Field type detection mapping
this.fieldTypeMap = {
'H1': { type: 'text', label: 'Headline', maxLength: 60, placeholder: 'Enter headline...' },
'H2': { type: 'text', label: 'Subheading', maxLength: 80, placeholder: 'Enter subheading...' },
'H3': { type: 'text', label: 'Section Title', maxLength: 100, placeholder: 'Enter title...' },
'H4': { type: 'text', label: 'Title', maxLength: 100, placeholder: 'Enter title...' },
'H5': { type: 'text', label: 'Title', maxLength: 100, placeholder: 'Enter title...' },
'H6': { type: 'text', label: 'Title', maxLength: 100, placeholder: 'Enter title...' },
'P': { type: 'textarea', label: 'Paragraph', rows: 3, placeholder: 'Enter paragraph text...' },
'A': { type: 'link', label: 'Link', placeholder: 'Enter link text...' },
'SPAN': { type: 'text', label: 'Text', placeholder: 'Enter text...' },
'CITE': { type: 'text', label: 'Citation', placeholder: 'Enter citation...' },
'BUTTON': { type: 'text', label: 'Button Text', placeholder: 'Enter button text...' }
};
this.editableElements = new Map();
this.statusIndicator = null;
if (this.options.autoInit) {
this.init();
}
}
async init() {
console.log('🚀 Insertr initializing with element-level editing...');
// Load content from localStorage
this.loadContentFromStorage();
// Scan for editable elements
this.scanForEditableElements();
// Setup authentication controls
this.setupAuthenticationControls();
// Create status indicator
this.createStatusIndicator();
// Apply initial state
this.updateBodyClasses();
console.log(`ðŸ“<EFBFBD> Found ${this.editableElements.size} editable elements`);
}
scanForEditableElements() {
const elements = document.querySelectorAll('.insertr');
elements.forEach(element => {
const contentId = element.getAttribute('data-content-id');
if (!contentId) {
console.warn('Insertr element missing data-content-id:', element);
return;
}
// Store reference and setup
this.editableElements.set(contentId, element);
this.setupEditableElement(element, contentId);
});
}
setupEditableElement(element, contentId) {
// Generate field configuration for this element
const fieldConfig = this.generateFieldConfig(element);
// Store field config on element
element._insertrConfig = fieldConfig;
// Add edit button (hidden by default)
this.addEditButton(element, contentId);
// Load saved content if available
const savedContent = this.state.contentCache.get(contentId);
if (savedContent) {
this.applyContentToElement(element, savedContent);
}
}
generateFieldConfig(element) {
const tagName = element.tagName;
let config = { ...this.fieldTypeMap[tagName] } || { type: 'text', label: 'Content', placeholder: 'Enter content...' };
// Enhance based on classes and context
if (element.classList.contains('lead')) {
config.label = 'Lead Paragraph';
config.rows = 4;
config.placeholder = 'Enter lead paragraph...';
}
if (element.classList.contains('btn-primary') || element.classList.contains('btn-secondary')) {
config.type = 'link';
config.label = 'Button';
config.includeUrl = true;
config.placeholder = 'Enter button text...';
}
if (element.classList.contains('section-subtitle')) {
config.label = 'Section Subtitle';
config.placeholder = 'Enter subtitle...';
}
// Special handling for certain content IDs
const contentId = element.getAttribute('data-content-id');
if (contentId && contentId.includes('cta')) {
config.label = 'Call to Action';
}
if (contentId && contentId.includes('quote')) {
config.type = 'textarea';
config.rows = 3;
config.label = 'Quote';
config.placeholder = 'Enter quote...';
}
return config;
}
addEditButton(element, contentId) {
// Remove existing edit button if any
const existingBtn = element.querySelector('.insertr-edit-btn');
if (existingBtn) existingBtn.remove();
// Create edit button
const editBtn = document.createElement('button');
editBtn.className = 'insertr-edit-btn';
editBtn.innerHTML = 'âœ<C3A2>ï¸<C3AF>';
editBtn.title = `Edit ${element._insertrConfig.label}`;
editBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.startEditing(contentId);
});
// Position relative for button placement
if (getComputedStyle(element).position === 'static') {
element.style.position = 'relative';
}
element.appendChild(editBtn);
}
startEditing(contentId) {
const element = this.editableElements.get(contentId);
if (!element || !this.state.editMode) return;
// Close any active editor
if (this.state.activeEditor && this.state.activeEditor !== contentId) {
this.cancelEditing(this.state.activeEditor);
}
const config = element._insertrConfig;
const currentContent = this.extractContentFromElement(element);
// Create and show edit form
const form = this.createEditForm(contentId, config, currentContent);
this.showEditForm(element, form);
this.state.activeEditor = contentId;
}
createEditForm(contentId, config, currentContent) {
const form = document.createElement('div');
form.className = 'insertr-edit-form';
let formHTML = `<div class="insertr-form-header">${config.label}</div>`;
if (config.type === 'link' && config.includeUrl) {
// Special handling for links - text and URL fields
const element = this.editableElements.get(contentId);
const currentUrl = element.href || '';
formHTML += `
<div class="insertr-form-group">
<label class="insertr-form-label">Link Text</label>
<input type="text" class="insertr-form-input" name="text"
value="${this.escapeHtml(currentContent)}"
placeholder="${config.placeholder}">
</div>
<div class="insertr-form-group">
<label class="insertr-form-label">Link URL</label>
<input type="url" class="insertr-form-input" name="url"
value="${this.escapeHtml(currentUrl)}"
placeholder="https://example.com">
</div>
`;
} else if (config.type === 'textarea') {
formHTML += `
<div class="insertr-form-group">
<textarea class="insertr-form-textarea" name="content"
rows="${config.rows || 3}"
placeholder="${config.placeholder}">${this.escapeHtml(currentContent)}</textarea>
</div>
`;
} else {
formHTML += `
<div class="insertr-form-group">
<input type="text" class="insertr-form-input" name="content"
value="${this.escapeHtml(currentContent)}"
placeholder="${config.placeholder}"
maxlength="${config.maxLength || ''}">
</div>
`;
}
formHTML += `
<div class="insertr-form-actions">
<button type="button" class="insertr-btn-cancel">Cancel</button>
<button type="button" class="insertr-btn-save">Save</button>
</div>
`;
form.innerHTML = formHTML;
// Bind events
form.querySelector('.insertr-btn-cancel').addEventListener('click', () => {
this.cancelEditing(contentId);
});
form.querySelector('.insertr-btn-save').addEventListener('click', () => {
this.saveElementContent(contentId, form);
});
// Focus on first input
setTimeout(() => {
const firstInput = form.querySelector('input, textarea');
if (firstInput) {
firstInput.focus();
if (firstInput.type === 'text' || firstInput.tagName === 'TEXTAREA') {
firstInput.select();
}
}
}, 100);
return form;
}
showEditForm(element, form) {
// Hide edit button during editing
const editBtn = element.querySelector('.insertr-edit-btn');
if (editBtn) editBtn.style.display = 'none';
// Create overlay container
const overlay = document.createElement('div');
overlay.className = 'insertr-edit-overlay';
overlay.appendChild(form);
// Position overlay near element
document.body.appendChild(overlay);
this.positionEditForm(element, overlay);
// Store reference for cleanup
element._insertrOverlay = overlay;
}
positionEditForm(element, overlay) {
const rect = element.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
overlay.style.position = 'absolute';
overlay.style.top = `${rect.bottom + scrollTop + 10}px`;
overlay.style.left = `${rect.left + scrollLeft}px`;
overlay.style.zIndex = '1000';
overlay.style.minWidth = `${Math.min(300, rect.width)}px`;
}
async saveElementContent(contentId, form) {
const element = this.editableElements.get(contentId);
if (!element) return;
const config = element._insertrConfig;
let newContent = {};
// Extract form data based on field type
if (config.type === 'link' && config.includeUrl) {
newContent.text = form.querySelector('input[name="text"]').value;
newContent.url = form.querySelector('input[name="url"]').value;
} else {
const input = form.querySelector('input[name="content"], textarea[name="content"]');
newContent.text = input.value;
}
// Add saving state
element.classList.add('insertr-saving');
try {
// Simulate save
await this.simulateSave(contentId, newContent);
// Update cache
this.state.contentCache.set(contentId, newContent);
this.saveContentToStorage();
// Apply new content to element
this.applyContentToElement(element, newContent);
// Close editor
this.cancelEditing(contentId);
// Show success state
element.classList.add('insertr-save-success');
setTimeout(() => {
element.classList.remove('insertr-save-success');
}, 2000);
} catch (error) {
console.error('Failed to save content:', error);
alert('Failed to save content. Please try again.');
} finally {
element.classList.remove('insertr-saving');
}
}
applyContentToElement(element, content) {
const config = element._insertrConfig;
if (config.type === 'link' && config.includeUrl && content.url !== undefined) {
// Update link text and URL
element.textContent = content.text || element.textContent;
if (content.url) {
element.href = content.url;
}
} else if (content.text !== undefined) {
// Update text content
element.textContent = content.text;
}
}
cancelEditing(contentId) {
const element = this.editableElements.get(contentId);
if (!element) return;
// Remove overlay
const overlay = element._insertrOverlay;
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
// Show edit button again
const editBtn = element.querySelector('.insertr-edit-btn');
if (editBtn) editBtn.style.display = '';
// Clear active editor
if (this.state.activeEditor === contentId) {
this.state.activeEditor = null;
}
delete element._insertrOverlay;
}
extractContentFromElement(element) {
// Clone element to avoid modifying original
const clone = element.cloneNode(true);
// Remove edit button from clone
const editBtn = clone.querySelector('.insertr-edit-btn');
if (editBtn) {
editBtn.remove();
}
// Extract clean text content
return clone.textContent.trim();
}
// Authentication and UI methods (unchanged from previous version)
setupAuthenticationControls() {
const authToggle = document.getElementById('auth-toggle');
const editModeToggle = document.getElementById('edit-mode-toggle');
if (authToggle) {
authToggle.addEventListener('click', () => {
this.toggleAuthentication();
});
}
if (editModeToggle) {
editModeToggle.addEventListener('click', () => {
this.toggleEditMode();
});
}
}
toggleAuthentication() {
this.state.isAuthenticated = !this.state.isAuthenticated;
const authToggle = document.getElementById('auth-toggle');
const editModeToggle = document.getElementById('edit-mode-toggle');
if (this.state.isAuthenticated) {
authToggle.textContent = 'Logout';
authToggle.className = 'btn-secondary';
editModeToggle.style.display = 'block';
this.state.currentUser = { name: 'Demo Client', role: 'editor' };
} else {
authToggle.textContent = 'Login as Client';
authToggle.className = 'btn-secondary';
editModeToggle.style.display = 'none';
this.state.editMode = false;
this.state.currentUser = null;
// Close any active editor
if (this.state.activeEditor) {
this.cancelEditing(this.state.activeEditor);
}
}
this.updateBodyClasses();
this.updateStatusIndicator();
}
toggleEditMode() {
if (!this.state.isAuthenticated) return;
// Close any active editor when toggling edit mode
if (this.state.activeEditor) {
this.cancelEditing(this.state.activeEditor);
}
this.state.editMode = !this.state.editMode;
const editModeToggle = document.getElementById('edit-mode-toggle');
if (editModeToggle) {
editModeToggle.textContent = `Edit Mode: ${this.state.editMode ? 'On' : 'Off'}`;
editModeToggle.className = this.state.editMode ? 'btn-primary' : 'btn-secondary';
}
this.updateBodyClasses();
this.updateStatusIndicator();
}
updateBodyClasses() {
document.body.classList.toggle('insertr-authenticated', this.state.isAuthenticated);
document.body.classList.toggle('insertr-edit-mode', this.state.editMode);
}
createStatusIndicator() {
this.statusIndicator = document.createElement('div');
this.statusIndicator.className = 'insertr-auth-status';
document.body.appendChild(this.statusIndicator);
this.updateStatusIndicator();
}
updateStatusIndicator() {
if (!this.statusIndicator) return;
let status = 'Public View';
let className = 'insertr-auth-status';
if (this.state.isAuthenticated) {
if (this.state.editMode) {
status = 'âœ<C3A2>ï¸<C3AF> Edit Mode Active';
className += ' edit-mode';
} else {
status = '👤 Client Authenticated';
className += ' authenticated';
}
}
this.statusIndicator.textContent = status;
this.statusIndicator.className = className;
}
// Utility methods
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Storage methods
loadContentFromStorage() {
try {
const stored = localStorage.getItem(this.options.storageKey);
if (stored) {
const data = JSON.parse(stored);
this.state.contentCache = new Map(Object.entries(data));
}
} catch (error) {
console.warn('Failed to load content from storage:', error);
}
}
saveContentToStorage() {
try {
const data = Object.fromEntries(this.state.contentCache);
localStorage.setItem(this.options.storageKey, JSON.stringify(data));
} catch (error) {
console.warn('Failed to save content to storage:', error);
}
}
async simulateSave(contentId, content) {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
// Simulate occasional failures for testing
if (Math.random() < 0.05) {
throw new Error('Network error');
}
console.log(`💾 Saved content for ${contentId}:`, content);
}
}
// Auto-initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.insertr = new Insertr();
});
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = Insertr;
}