/**
* Rich Tagger JavaScript - Complete Rich Text Editor Implementation
*
* This file contains a comprehensive rich text editor class with advanced features
* including formatting, color selection, lists, alignment, undo/redo, and more.
*/
/**
* RichTaggerEditor Class
*
* A feature-rich WYSIWYG text editor with toolbar controls for formatting,
* styling, and content manipulation. Supports undo/redo, fullscreen mode,
* and real-time HTML output display.
*/
class RichTaggerEditor {
// ========================================
// CONSTRUCTOR AND INITIALIZATION
// ========================================
/**
* Constructor - Initialize the editor with container and options
* @param {string} containerId - ID of the container element
* @param {Object} options - Configuration options for the editor
*/
constructor(containerId, options = {}) {
this.container = document.getElementById(containerId);
// Default configuration with toolbar buttons and height
this.options = {
height: '350px',
toolbar: [
'fontsize', 'fontfamily', 'heading', '|',
'bold', 'italic', 'underline', 'strikethrough', '|',
'textcolor', 'highlight', '|',
'alignleft', 'aligncenter', 'alignright', '|',
'unorderedlist', 'orderedlist', 'codeblock', 'indent', '|',
'link', 'superscript', 'subscript', 'hr', '|',
'find', 'insertdate', 'fullscreen', 'clearformat', '|',
'undo', 'redo'
],
...options
};
// Initialize undo/redo system properties
this.undoStack = []; // Stack for undo operations
this.redoStack = []; // Stack for redo operations
this.maxUndoSteps = 50; // Maximum number of undo steps to keep
this.savedRange = null; // Saved selection range for dropdown operations
// Start the initialization process
this.init();
}
/**
* Initialize the editor components in sequence
* Creates toolbar, editor area, and binds event handlers
*/
init() {
this.createToolbar(); // Create the formatting toolbar
this.createEditor(); // Create the content editor area
this.bindEvents(); // Attach event listeners
}
// ========================================
// TOOLBAR CREATION AND MANAGEMENT
// ========================================
/**
* Create the toolbar with all formatting buttons and dropdowns
* Builds toolbar based on the options.toolbar configuration
*/
createToolbar() {
// Create main toolbar container
this.toolbar = document.createElement('div');
this.toolbar.className = 'rte-toolbar d-flex flex-wrap gap-1';
// Define all available toolbar buttons with their properties
const buttons = {
// Font and text styling
fontsize: {
type: 'dropdown',
icon: 'Size',
action: 'fontsize',
title: 'Font Size',
options: ['8px', '10px', '12px', '14px', '16px', '18px', '24px', '36px']
},
fontfamily: {
type: 'dropdown',
icon: 'Font',
action: 'fontfamily',
title: 'Font Family',
options: ['Arial', 'Times New Roman', 'Helvetica', 'Georgia', 'Courier New']
},
heading: {
type: 'dropdown',
icon: 'Heading',
action: 'heading',
title: 'H1, H2, H3',
options: ['Normal', 'H1', 'H2', 'H3']
},
// Basic formatting buttons
bold: { icon: 'B', action: 'bold', title: 'Bold (Ctrl+B)' },
italic: { icon: 'I', action: 'italic', title: 'Italic (Ctrl+I)' },
underline: { icon: 'U', action: 'underline', title: 'Underline (Ctrl+U)' },
strikethrough: { icon: 'S', action: 'strikethrough', title: 'Strikethrough' },
// Color formatting
textcolor: {
type: 'dropdown',
icon: 'A',
action: 'textcolor',
title: 'Text Colour',
options: ['black', 'red', 'blue', 'green', 'purple', 'orange', 'brown', 'gray', 'darkred', 'darkblue', 'darkgreen', 'darkviolet', 'darkorange', 'darkgray', 'pink', 'cyan', 'lime', 'gold']
},
highlight: {
type: 'dropdown',
icon: 'A',
action: 'highlight',
title: 'Text Highlight',
options: ['yellow', 'lightgreen', 'lightblue', 'pink', 'orange', 'lavender', 'lightyellow', 'lightcyan', 'lightpink', 'peachpuff', 'lightgray', 'lightcoral', 'mistyrose', 'lightsteelblue', 'lightseagreen', 'moccasin', 'thistle', 'lemonchiffon']
},
// Text alignment
alignleft: { icon: '', action: 'alignleft', title: 'Align Left' },
aligncenter: { icon: '', action: 'aligncenter', title: 'Align Center' },
alignright: { icon: '', action: 'alignright', title: 'Align Right' },
// Content insertion and structure
link: { icon: '🔗', action: 'link', title: 'Insert Link' },
unorderedlist: { icon: '', action: 'unorderedlist', title: 'Bullet List' },
orderedlist: { icon: '', action: 'orderedlist', title: 'Numbered List' },
codeblock: { icon: '{ }', action: 'codeblock', title: 'Code Block' },
indent: { icon: '', action: 'indent', title: 'Toggle Indent' },
// Advanced formatting
superscript: { icon: 'X²', action: 'superscript', title: 'Superscript' },
subscript: { icon: 'X₂', action: 'subscript', title: 'Subscript' },
hr: { icon: '—', action: 'hr', title: 'Insert Horizontal Rule' },
// Editor tools
find: { icon: '🔍', action: 'find', title: 'Find & Replace (Ctrl+F)' },
insertdate: { icon: '📅', action: 'insertdate', title: 'Insert Current Date' },
fullscreen: { icon: '⛶', action: 'fullscreen', title: 'Toggle Fullscreen' },
clearformat: { icon: '🗑️', action: 'clearformat', title: 'Clear Formatting' },
// Undo/Redo
undo: { icon: '↶', action: 'undo', title: 'Undo (Ctrl+Z)' },
redo: { icon: '↷', action: 'redo', title: 'Redo (Ctrl+Y)' }
};
// Build toolbar by iterating through configured buttons
this.options.toolbar.forEach(buttonName => {
if (buttonName === '|') {
// Create visual divider between button groups
const divider = document.createElement('div');
divider.className = 'toolbar-divider';
divider.style.cssText = 'width: 1px; height: 24px; background-color: #dee2e6; margin: 0 4px; align-self: center;';
this.toolbar.appendChild(divider);
} else if (buttons[buttonName]) {
// Create button or dropdown for this toolbar item
const btn = this.createToolbarButton(buttons[buttonName]);
this.toolbar.appendChild(btn);
}
});
// Add completed toolbar to the container
this.container.appendChild(this.toolbar);
}
/**
* Create individual toolbar button (regular button or dropdown)
* @param {Object} config - Button configuration object
* @returns {HTMLElement} The created button element
*/
createToolbarButton(config) {
if (config.type === 'dropdown') {
return this.createDropdown(config);
}
// Create regular toolbar button
const button = document.createElement('button');
button.type = 'button';
button.className = 'rte-toolbar-btn btn btn-sm btn-outline-secondary';
button.innerHTML = config.icon;
button.title = config.title;
button.dataset.action = config.action;
// Add click handler to execute the button's action
button.onclick = (e) => {
e.preventDefault();
this.executeAction(config.action);
};
return button;
}
/**
* Create dropdown button with options (for font size, color, etc.)
* @param {Object} config - Dropdown configuration object
* @returns {HTMLElement} The created dropdown element
*/
createDropdown(config) {
const dropdown = document.createElement('div');
dropdown.className = 'dropdown';
// Special styling for color picker dropdowns
const isColorDropdown = config.action === 'textcolor' || config.action === 'highlight';
// Build dropdown HTML structure
dropdown.innerHTML = `
${isColorDropdown ?
// Color grid layout for color dropdowns
`
Start typing here to explore all the advanced features...
'; // Add editor to container this.container.appendChild(this.editor); } // ======================================== // EVENT BINDING AND HANDLERS // ======================================== /** * Bind all necessary event listeners to the editor * Handles input, selection changes, keyboard shortcuts, and state saving */ bindEvents() { // Save initial state for undo functionality this.saveState(); // Update toolbar button states when selection changes this.editor.addEventListener('mouseup', () => this.updateToolbarState()); this.editor.addEventListener('keyup', () => this.updateToolbarState()); // Handle content changes this.editor.addEventListener('input', () => { this.updateOutput(); this.updateToolbarState(); // Debounced state saving to avoid excessive undo entries clearTimeout(this.saveTimeout); this.saveTimeout = setTimeout(() => this.saveState(), 500); }); // Update output when editor loses focus this.editor.addEventListener('blur', () => this.updateOutput()); // Save state on important key presses this.editor.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete') { clearTimeout(this.saveTimeout); this.saveTimeout = setTimeout(() => this.saveState(), 100); } }); // Initial output update after brief delay setTimeout(() => { this.updateOutput(); this.updateToolbarState(); }, 100); } // ======================================== // ACTION EXECUTION AND COORDINATION // ======================================== /** * Execute a toolbar action (formatting, insertion, etc.) * @param {string} action - The action to perform * @param {string} value - Optional value for the action (e.g., color, font size) */ executeAction(action, value = null) { this.editor.focus(); // Restore saved selection range if available (from dropdown usage) let range = null; if (this.savedRange) { range = this.savedRange; const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(this.savedRange); this.savedRange = null; } else { const selection = window.getSelection(); range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null; } // Route to appropriate method based on action type switch(action) { // Font and text styling case 'fontsize': this.setFontSize(range, value); break; case 'fontfamily': this.setFontFamily(range, value); break; case 'bold': this.toggleFormat(range, 'strong'); break; case 'italic': this.toggleFormat(range, 'em'); break; case 'underline': this.toggleFormat(range, 'u'); break; case 'strikethrough': this.toggleFormat(range, 's'); break; case 'textcolor': if (value) this.setTextColor(range, value); break; case 'highlight': if (value) this.setBackgroundColor(range, value); break; // Headings and structure case 'heading': this.formatHeading(range, value); break; case 'h1': case 'h2': case 'h3': this.setHeading(range, action); break; // Text alignment case 'alignleft': case 'aligncenter': case 'alignright': this.alignText(range, action.replace('align', '')); break; // Content insertion case 'link': this.insertLink(range); break; case 'unorderedlist': this.toggleList(range, 'ul'); break; case 'orderedlist': this.toggleList(range, 'ol'); break; case 'codeblock': this.insertCodeBlock(range); break; case 'indent': this.toggleIndent(range); break; case 'superscript': this.toggleFormat(range, 'sup'); break; case 'subscript': this.toggleFormat(range, 'sub'); break; case 'hr': this.insertHorizontalRule(range); break; // Editor tools case 'find': this.openFindReplace(); break; case 'insertdate': this.insertDateTime('date'); break; case 'fullscreen': this.toggleFullscreen(); break; case 'clearformat': this.clearFormatting(range); break; // Undo/Redo (don't save state for these) case 'undo': this.undo(); return; case 'redo': this.redo(); return; } // Update UI and save state after action (except undo/redo) this.updateOutput(); this.updateToolbarState(); this.saveState(); } // ======================================== // TOOLBAR STATE MANAGEMENT // ======================================== /** * Update toolbar button states based on current selection * Highlights active formatting buttons and updates dropdown displays */ updateToolbarState() { const selection = window.getSelection(); if (selection.rangeCount === 0) return; const range = selection.getRangeAt(0); const buttons = this.toolbar.querySelectorAll('.rte-toolbar-btn'); // Check each button to see if its formatting is active buttons.forEach(btn => { btn.classList.remove('active'); if (btn.dataset.action) { const action = btn.dataset.action; let isActive = false; // Determine if this formatting is currently applied switch(action) { case 'bold': isActive = this.isSelectionFormatted(range, 'strong'); break; case 'italic': isActive = this.isSelectionFormatted(range, 'em'); break; case 'underline': isActive = this.isSelectionFormatted(range, 'u'); break; case 'strikethrough': isActive = this.isSelectionFormatted(range, 's'); break; case 'superscript': isActive = this.isSelectionFormatted(range, 'sup'); break; case 'subscript': isActive = this.isSelectionFormatted(range, 'sub'); break; case 'heading': // Update heading dropdown to show current level const headingLevel = this.getCurrentHeading(range); btn.innerHTML = headingLevel || 'Heading'; break; case 'codeblock': isActive = this.isCurrentCodeBlock(range); break; case 'indent': isActive = this.isCurrentlyIndented(range); break; } // Apply active styling if this formatting is present if (isActive) { btn.classList.add('active'); } } }); } /** * Check if current selection is within a specific heading level * @param {Range} range - The current selection range * @param {string} level - The heading level to check (h1, h2, h3) * @returns {boolean} True if selection is in the specified heading */ isCurrentHeading(range, level) { let element = range.commonAncestorContainer; if (element.nodeType === Node.TEXT_NODE) { element = element.parentElement; } // Walk up DOM tree to find block-level element while (element && element !== this.editor && !['P', 'DIV', 'H1', 'H2', 'H3', 'H4'].includes(element.tagName)) { element = element.parentElement; } return element && element.tagName && element.tagName.toLowerCase() === level.toLowerCase(); } /** * Get the current heading level of the selection * @param {Range} range - The current selection range * @returns {string} The heading level (H1, H2, H3) or 'Normal' */ getCurrentHeading(range) { if (!range) return 'Heading'; let element = range.commonAncestorContainer; if (element.nodeType === Node.TEXT_NODE) { element = element.parentElement; } // Find heading element in parent chain while (element && element !== this.editor) { if (element.tagName && ['H1', 'H2', 'H3'].includes(element.tagName)) { return element.tagName; } element = element.parentElement; } return 'Normal'; } /** * Check if current selection is within a code block * @param {Range} range - The current selection range * @returns {boolean} True if selection is in a code block */ isCurrentCodeBlock(range) { if (!range) return false; let element = range.commonAncestorContainer; if (element.nodeType === Node.TEXT_NODE) { element = element.parentElement; } // Look for pre element in parent chain while (element && element !== this.editor) { if (element.tagName && element.tagName.toLowerCase() === 'pre') { return true; } element = element.parentElement; } return false; } /** * Check if current selection is indented * @param {Range} range - The current selection range * @returns {boolean} True if selection is indented */ isCurrentlyIndented(range) { if (!range) return false; let blockElement = range.commonAncestorContainer; if (blockElement.nodeType === Node.TEXT_NODE) { blockElement = blockElement.parentElement; } // Find block-level element while (blockElement && blockElement !== this.editor && !['P', 'DIV', 'H1', 'H2', 'H3', 'LI', 'BLOCKQUOTE'].includes(blockElement.tagName)) { blockElement = blockElement.parentElement; } if (blockElement && blockElement !== this.editor) { const currentIndent = blockElement.style.marginLeft || '0px'; return currentIndent !== '0px' && currentIndent !== ''; } return false; } // ======================================== // TEXT FORMATTING METHODS // ======================================== /** * Set font size for selected text * @param {Range} range - The selection range * @param {string} size - Font size (e.g., '16px') */ setFontSize(range, size) { if (range && !range.collapsed) { // Remove existing font-size styling first this.removeStyleFromRange(range, 'fontSize'); // Apply new font size const span = document.createElement('span'); span.style.fontSize = size; try { range.surroundContents(span); } catch(e) { const contents = range.extractContents(); span.appendChild(contents); range.insertNode(span); } } } /** * Set font family for selected text * @param {Range} range - The selection range * @param {string} family - Font family name */ setFontFamily(range, family) { if (range && !range.collapsed) { // Remove existing font-family styling first this.removeStyleFromRange(range, 'fontFamily'); // Apply new font family const span = document.createElement('span'); span.style.fontFamily = family; try { range.surroundContents(span); } catch(e) { const contents = range.extractContents(); span.appendChild(contents); range.insertNode(span); } } } /** * Set text color for selected text * @param {Range} range - The selection range * @param {string} color - Color value */ setTextColor(range, color) { if (range && !range.collapsed) { // Remove existing text color styling first this.removeStyleFromRange(range, 'color'); // Apply new text color const span = document.createElement('span'); span.style.color = color; try { range.surroundContents(span); } catch(e) { const contents = range.extractContents(); span.appendChild(contents); range.insertNode(span); } } } /** * Set background color (highlight) for selected text * @param {Range} range - The selection range * @param {string} color - Background color value */ setBackgroundColor(range, color) { if (range && !range.collapsed) { // Remove existing background color styling first this.removeStyleFromRange(range, 'backgroundColor'); // Apply new background color const span = document.createElement('span'); span.style.backgroundColor = color; try { range.surroundContents(span); } catch(e) { const contents = range.extractContents(); span.appendChild(contents); range.insertNode(span); } } } /** * Toggle formatting (bold, italic, underline, etc.) on selected text * @param {Range} range - The selection range * @param {string} tagName - HTML tag name for the formatting */ toggleFormat(range, tagName) { if (range && !range.collapsed) { // Check if formatting is already applied if (this.isSelectionFormatted(range, tagName)) { // Remove the formatting this.removeFormatting(range, tagName); } else { // Apply the formatting const element = document.createElement(tagName); try { range.surroundContents(element); } catch(e) { const contents = range.extractContents(); element.appendChild(contents); range.insertNode(element); } } } } /** * Check if selection has specific formatting applied * @param {Range} range - The selection range * @param {string} tagName - HTML tag name to check for * @returns {boolean} True if formatting is present */ isSelectionFormatted(range, tagName) { let container = range.commonAncestorContainer; // If container is text node, check its parent if (container.nodeType === Node.TEXT_NODE) { container = container.parentElement; } // Check parent elements for the target tag let current = container; while (current && current !== this.editor) { if (current.tagName && current.tagName.toLowerCase() === tagName.toLowerCase()) { return true; } current = current.parentElement; } // Also check if selection contains the target tag if (container.querySelector) { const targetElements = container.querySelectorAll(tagName.toLowerCase()); for (let element of targetElements) { if (range.intersectsNode(element)) { return true; } } } return false; } /** * Remove specific formatting from selected range * @param {Range} range - The selection range * @param {string} tagName - HTML tag name to remove */ removeFormatting(range, tagName) { const selection = window.getSelection(); const originalRange = range.cloneRange(); let container = range.commonAncestorContainer; if (container.nodeType === Node.TEXT_NODE) { container = container.parentElement; } // Remove from parent elements first let current = container; while (current && current !== this.editor) { if (current.tagName && current.tagName.toLowerCase() === tagName.toLowerCase()) { this.unwrapElement(current); break; } current = current.parentElement; } // Remove from nested elements within selection if (container.querySelectorAll) { const elements = Array.from(container.querySelectorAll(tagName.toLowerCase())); elements.forEach(element => { if (originalRange.intersectsNode(element)) { this.unwrapElement(element); } }); } // Restore selection try { selection.removeAllRanges(); selection.addRange(originalRange); } catch(e) { // Selection restoration failed but formatting was still applied } } /** * Remove specific CSS style from elements in range * @param {Range} range - The selection range * @param {string} styleProperty - CSS property to remove */ removeStyleFromRange(range, styleProperty) { const container = range.commonAncestorContainer; let elementsToCheck = []; if (container.nodeType === Node.TEXT_NODE) { // Check parent elements let parent = container.parentElement; while (parent && parent !== this.editor) { if (parent.style && parent.style[styleProperty]) { elementsToCheck.push(parent); } parent = parent.parentElement; } } else { // Check all child elements const spans = container.querySelectorAll ? container.querySelectorAll('span') : []; spans.forEach(span => { if (span.style && span.style[styleProperty]) { elementsToCheck.push(span); } }); } // Remove the style property from found elements elementsToCheck.forEach(element => { element.style[styleProperty] = ''; // Remove element entirely if no other styles remain if (!element.getAttribute('style') || element.getAttribute('style').trim() === '') { this.unwrapElement(element); } }); } /** * Remove an element while keeping its content * @param {HTMLElement} element - Element to unwrap */ unwrapElement(element) { const parent = element.parentNode; while (element.firstChild) { parent.insertBefore(element.firstChild, element); } parent.removeChild(element); } // ======================================== // CONTENT STRUCTURE METHODS // ======================================== /** * Format current block as heading or normal text * @param {Range} range - The selection range * @param {string} value - Heading level (H1, H2, H3) or 'Normal' */ formatHeading(range, value) { if (!range) return; // Find the current block element let blockElement = range.commonAncestorContainer; if (blockElement.nodeType === Node.TEXT_NODE) { blockElement = blockElement.parentElement; } // Walk up to find paragraph or heading element while (blockElement && blockElement !== this.editor && !['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(blockElement.tagName)) { blockElement = blockElement.parentElement; } if (blockElement && blockElement !== this.editor) { let newElement; // Create appropriate element type if (value === 'Normal') { newElement = document.createElement('p'); } else { newElement = document.createElement(value.toLowerCase()); } // Copy content and replace element newElement.innerHTML = blockElement.innerHTML; blockElement.parentNode.replaceChild(newElement, blockElement); // Restore selection to new element const selection = window.getSelection(); selection.removeAllRanges(); const newRange = document.createRange(); newRange.selectNodeContents(newElement); newRange.collapse(false); selection.addRange(newRange); } } /** * Set heading level for current block * @param {Range} range - The selection range * @param {string} level - Heading level (h1, h2, h3) */ setHeading(range, level) { let element = range.commonAncestorContainer; if (element.nodeType === Node.TEXT_NODE) { element = element.parentElement; } // Find block-level element while (element && element !== this.editor && !['P', 'DIV', 'H1', 'H2', 'H3', 'H4'].includes(element.tagName)) { element = element.parentElement; } if (element && element !== this.editor) { const currentTag = element.tagName.toLowerCase(); const targetTag = level.toLowerCase(); // Toggle between heading and paragraph if same level if (currentTag === targetTag) { const paragraph = document.createElement('p'); paragraph.innerHTML = element.innerHTML; if (element.style.cssText) { paragraph.style.cssText = element.style.cssText; } element.parentNode.replaceChild(paragraph, element); } else { // Convert to requested heading level const heading = document.createElement(level.toUpperCase()); heading.innerHTML = element.innerHTML; if (element.style.cssText) { heading.style.cssText = element.style.cssText; } element.parentNode.replaceChild(heading, element); } } } /** * Set text alignment for current block * @param {Range} range - The selection range * @param {string} alignment - Alignment value (left, center, right) */ alignText(range, alignment) { let element = range.commonAncestorContainer; if (element.nodeType === Node.TEXT_NODE) { element = element.parentElement; } // Find block-level element while (element && element !== this.editor && !['P', 'DIV', 'H1', 'H2', 'H3', 'H4'].includes(element.tagName)) { element = element.parentElement; } if (element && element !== this.editor) { element.style.textAlign = alignment; } } /** * Toggle indentation for current block * @param {Range} range - The selection range */ toggleIndent(range) { if (!range) return; // Find block element let blockElement = range.commonAncestorContainer; if (blockElement.nodeType === Node.TEXT_NODE) { blockElement = blockElement.parentElement; } while (blockElement && blockElement !== this.editor && !['P', 'DIV', 'H1', 'H2', 'H3', 'LI', 'BLOCKQUOTE'].includes(blockElement.tagName)) { blockElement = blockElement.parentElement; } if (blockElement && blockElement !== this.editor) { const currentIndent = blockElement.style.marginLeft || '0px'; // Toggle between indented and normal if (currentIndent === '0px' || currentIndent === '') { blockElement.style.marginLeft = '40px'; } else { blockElement.style.marginLeft = '0px'; } } } /** * Insert or toggle code block formatting * @param {Range} range - The selection range */ insertCodeBlock(range) { if (!range) return; // Find current block element let blockElement = range.commonAncestorContainer; if (blockElement.nodeType === Node.TEXT_NODE) { blockElement = blockElement.parentElement; } while (blockElement && blockElement !== this.editor && !['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'PRE', 'LI'].includes(blockElement.tagName)) { blockElement = blockElement.parentElement; } if (blockElement && blockElement !== this.editor) { if (blockElement.tagName.toLowerCase() === 'pre') { // Convert code block back to paragraph const paragraph = document.createElement('p'); const codeElement = blockElement.querySelector('code'); if (codeElement) { paragraph.textContent = codeElement.textContent; } else { paragraph.textContent = blockElement.textContent; } blockElement.parentNode.replaceChild(paragraph, blockElement); } else { // Convert to code block const pre = document.createElement('pre'); const code = document.createElement('code'); pre.appendChild(code); pre.className = 'bg-dark text-light p-3 rounded'; code.textContent = blockElement.textContent || 'Enter code here...'; blockElement.parentNode.replaceChild(pre, blockElement); } } } // ======================================== // CONTENT INSERTION METHODS // ======================================== /** * Insert a hyperlink at current selection * @param {Range} range - The selection range */ insertLink(range) { const url = prompt('Enter URL:', 'https://'); if (url && url !== 'https://') { const link = document.createElement('a'); link.href = url; link.target = '_blank'; if (range.collapsed) { // No selection, use URL as link text link.textContent = url; range.insertNode(link); } else { // Wrap selected text in link try { range.surroundContents(link); } catch(e) { const contents = range.extractContents(); link.appendChild(contents); range.insertNode(link); } } } } /** * Create a list from selected text * @param {Range} range - The selection range * @param {string} listType - List type ('ul' or 'ol') */ toggleList(range, listType) { if (!range.collapsed) { const list = document.createElement(listType); const listItem = document.createElement('li'); try { const contents = range.extractContents(); listItem.appendChild(contents); list.appendChild(listItem); range.insertNode(list); } catch(e) { console.warn('List creation failed:', e); } } } /** * Insert horizontal rule at cursor * @param {Range} range - The selection range */ insertHorizontalRule(range) { const hr = document.createElement('hr'); range.insertNode(hr); // Move cursor after the hr range.setStartAfter(hr); range.collapse(true); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } /** * Insert current date at cursor position * @param {string} type - Type of date insertion ('date' or 'time') */ insertDateTime(type) { const now = new Date(); const text = type === 'date' ? now.toLocaleDateString() : now.toLocaleTimeString(); const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(text)); } } // ======================================== // EDITOR TOOLS AND UTILITIES // ======================================== /** * Open find and replace dialog * Simple implementation using browser prompts */ openFindReplace() { const findText = prompt('Find text:'); if (!findText) return; const replaceText = prompt('Replace with:', ''); if (replaceText === null) return; // Perform global case-insensitive replacement const content = this.editor.innerHTML; const regex = new RegExp(findText, 'gi'); const newContent = content.replace(regex, replaceText); this.editor.innerHTML = newContent; } /** * Toggle fullscreen mode for the editor */ toggleFullscreen() { this.container.classList.toggle('rte-fullscreen'); if (this.container.classList.contains('rte-fullscreen')) { // Enter fullscreen mode this.editor.style.height = 'calc(100vh - 200px)'; this.container.style.position = 'fixed'; this.container.style.top = '0'; this.container.style.left = '0'; this.container.style.width = '100%'; this.container.style.height = '100%'; this.container.style.backgroundColor = 'white'; this.container.style.zIndex = '9999'; this.container.style.padding = '20px'; } else { // Exit fullscreen mode this.editor.style.height = this.options.height; this.container.style.position = ''; this.container.style.top = ''; this.container.style.left = ''; this.container.style.width = ''; this.container.style.height = ''; this.container.style.backgroundColor = ''; this.container.style.zIndex = ''; this.container.style.padding = ''; } } /** * Clear all formatting from selected text * @param {Range} range - The selection range */ clearFormatting(range) { if (!range.collapsed) { const text = range.toString(); range.deleteContents(); range.insertNode(document.createTextNode(text)); } } // ======================================== // UNDO/REDO SYSTEM // ======================================== /** * Save current editor state for undo functionality */ saveState() { const currentState = this.editor.innerHTML; // Don't save duplicate states if (this.undoStack.length > 0 && this.undoStack[this.undoStack.length - 1] === currentState) { return; } this.undoStack.push(currentState); // Limit stack size to prevent memory issues if (this.undoStack.length > this.maxUndoSteps) { this.undoStack.shift(); } // Clear redo stack when new action is performed this.redoStack = []; this.updateUndoRedoButtons(); } /** * Undo last action */ undo() { if (this.undoStack.length > 1) { // Move current state to redo stack const currentState = this.undoStack.pop(); this.redoStack.push(currentState); // Restore previous state const previousState = this.undoStack[this.undoStack.length - 1]; this.editor.innerHTML = previousState; this.updateOutput(); this.updateUndoRedoButtons(); } } /** * Redo last undone action */ redo() { if (this.redoStack.length > 0) { // Move state from redo stack back to undo stack const nextState = this.redoStack.pop(); this.undoStack.push(nextState); // Restore next state this.editor.innerHTML = nextState; this.updateOutput(); this.updateUndoRedoButtons(); } } /** * Update undo/redo button states * Disables buttons when stacks are empty */ updateUndoRedoButtons() { const undoBtn = this.toolbar.querySelector('[data-action="undo"]'); const redoBtn = this.toolbar.querySelector('[data-action="redo"]'); if (undoBtn) { undoBtn.disabled = this.undoStack.length <= 1; undoBtn.style.opacity = undoBtn.disabled ? '0.5' : '1'; } if (redoBtn) { redoBtn.disabled = this.redoStack.length === 0; redoBtn.style.opacity = redoBtn.disabled ? '0.5' : '1'; } } // ======================================== // CONTENT MANAGEMENT AND OUTPUT // ======================================== /** * Update HTML output displays on the page * Shows raw HTML and sanitized content in designated areas */ updateOutput() { const htmlOutput = document.getElementById('advanced-html-output'); const sanitizedOutput = document.getElementById('advanced-sanitized-output'); if (htmlOutput) { const content = this.getContent(); htmlOutput.textContent = content; } if (sanitizedOutput) { const sanitized = this.sanitizeContent(this.getContent()); sanitizedOutput.innerHTML = sanitized; } } /** * Get current editor content as HTML * @returns {string} The HTML content of the editor */ getContent() { return this.editor.innerHTML; } /** * Set editor content * @param {string} html - HTML content to set */ setContent(html) { this.editor.innerHTML = html; this.updateOutput(); } /** * Clear editor content */ clear() { this.editor.innerHTML = '