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.
535 lines
19 KiB
JavaScript
535 lines
19 KiB
JavaScript
/**
|
||
* 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;
|
||
} |