editium
Version:
A powerful and feature-rich React rich text editor component built with Slate.js, featuring comprehensive formatting options, tables, images, find & replace, and more
1,351 lines (1,133 loc) • 116 kB
JavaScript
class Editium {
constructor(options = {}) {
this.container = options.container;
this.placeholder = options.placeholder || 'Start typing...';
this.toolbar = options.toolbar || ['bold', 'italic', 'underline', 'heading-one', 'heading-two', 'bulleted-list', 'numbered-list', 'link'];
this.onChange = options.onChange || (() => {});
this.readOnly = options.readOnly || false;
this.showWordCount = options.showWordCount || false;
this.className = options.className || '';
this.onImageUpload = options.onImageUpload || null;
this.height = options.height || '200px';
this.minHeight = options.minHeight || '150px';
this.maxHeight = options.maxHeight || '250px';
this.isFullscreen = false;
this.searchQuery = '';
this.searchMatches = [];
this.currentMatchIndex = 0;
this.findReplacePanel = null;
this.history = [];
this.historyIndex = -1;
this.maxHistory = 50;
this.openDropdown = null;
this.linkPopup = null;
this.selectedLink = null;
this.showEmojiPicker = false;
this.emojiPickerElement = null;
if (!this.container) {
throw new Error('Container element is required');
}
this.init();
}
init() {
this.createEditor();
this.attachEventListeners();
if (this.editor.innerHTML.trim() === '') this.editor.innerHTML = '<p><br></p>';
this.makeExistingImagesResizable();
this.makeExistingLinksNonEditable();
this.saveState();
}
createEditor() {
this.container.innerHTML = '';
this.wrapper = document.createElement('div');
this.wrapper.className = `editium-wrapper ${this.className}`;
if (this.isFullscreen) this.wrapper.classList.add('editium-fullscreen');
const toolbarItems = this.toolbar === 'all' ? this.getAllToolbarItems() : this.toolbar;
if (toolbarItems.length > 0) {
this.toolbarElement = this.createToolbar(toolbarItems);
this.wrapper.appendChild(this.toolbarElement);
}
this.editorContainer = document.createElement('div');
this.editorContainer.className = 'editium-editor-container';
this.editor = document.createElement('div');
this.editor.className = 'editium-editor';
this.editor.contentEditable = !this.readOnly;
this.editor.setAttribute('data-placeholder', this.placeholder);
if (!this.isFullscreen) {
this.editor.style.height = typeof this.height === 'number' ? `${this.height}px` : this.height;
this.editor.style.minHeight = typeof this.minHeight === 'number' ? `${this.minHeight}px` : this.minHeight;
this.editor.style.maxHeight = typeof this.maxHeight === 'number' ? `${this.maxHeight}px` : this.maxHeight;
} else {
this.editor.style.height = 'auto';
this.editor.style.minHeight = 'auto';
this.editor.style.maxHeight = 'none';
}
this.editorContainer.appendChild(this.editor);
this.wrapper.appendChild(this.editorContainer);
this.wordCountElement = document.createElement('div');
this.wordCountElement.className = 'editium-word-count';
this.wrapper.appendChild(this.wordCountElement);
this.updateWordCount();
this.container.appendChild(this.wrapper);
}
getAllToolbarItems() {
return [
'paragraph', 'heading-one', 'heading-two', 'heading-three', 'heading-four',
'heading-five', 'heading-six',
'separator',
'bold', 'italic', 'underline', 'strikethrough',
'separator',
'superscript', 'subscript', 'code',
'separator',
'left', 'center', 'right', 'justify',
'separator',
'text-color', 'bg-color',
'separator',
'blockquote', 'code-block',
'separator',
'bulleted-list', 'numbered-list', 'indent', 'outdent',
'separator',
'link', 'image', 'table', 'horizontal-rule', 'undo', 'redo',
'separator',
'import-docx', 'export-docx', 'export-pdf',
'separator',
'emoji',
'separator',
'find-replace', 'fullscreen', 'view-output'
];
}
createToolbar(items) {
const toolbar = document.createElement('div');
toolbar.className = 'editium-toolbar';
const groups = {
paragraph: ['paragraph', 'heading-one', 'heading-two', 'heading-three', 'heading-four', 'heading-five', 'heading-six'],
format: ['bold', 'italic', 'underline', 'strikethrough', 'code', 'superscript', 'subscript'],
align: ['left', 'center', 'right', 'justify'],
color: ['text-color', 'bg-color'],
blocks: ['blockquote', 'code-block'],
lists: ['bulleted-list', 'numbered-list', 'indent', 'outdent'],
insert: ['link', 'image', 'table', 'horizontal-rule'],
edit: ['undo', 'redo'],
file: ['import-docx', 'export-docx', 'export-pdf'],
view: ['preview', 'view-html', 'view-json']
};
if (this.toolbar === 'all') {
toolbar.appendChild(this.createBlockFormatDropdown());
toolbar.appendChild(this.createGroupDropdown('Format', groups.format));
toolbar.appendChild(this.createAlignmentDropdown());
toolbar.appendChild(this.createGroupDropdown('Color', groups.color));
toolbar.appendChild(this.createGroupDropdown('Blocks', groups.blocks));
toolbar.appendChild(this.createGroupDropdown('Lists', groups.lists));
toolbar.appendChild(this.createGroupDropdown('Insert', groups.insert));
toolbar.appendChild(this.createGroupDropdown('Edit', groups.edit));
toolbar.appendChild(this.createGroupDropdown('File', groups.file));
toolbar.appendChild(this.createGroupDropdown('View', groups.view));
const emojiWrapper = document.createElement('div');
emojiWrapper.style.cssText = 'position: relative; display: inline-block;';
const emojiButton = this.createToolbarButton('emoji');
if (emojiButton) {
emojiWrapper.appendChild(emojiButton);
toolbar.appendChild(emojiWrapper);
this._emojiWrapper = emojiWrapper;
}
const spacer = document.createElement('div');
spacer.style.flex = '1';
toolbar.appendChild(spacer);
const findButton = this.createToolbarButton('find-replace');
const fullscreenButton = this.createToolbarButton('fullscreen');
if (findButton) toolbar.appendChild(findButton);
if (fullscreenButton) toolbar.appendChild(fullscreenButton);
} else {
const blockFormats = groups.paragraph;
const alignments = groups.align;
const fileItems = groups.file;
let processedGroups = { block: false, align: false, file: false };
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item === 'separator') {
if (i > 0 && items[i-1] !== 'separator') {
const separator = document.createElement('div');
separator.className = 'editium-toolbar-separator';
toolbar.appendChild(separator);
}
} else if (blockFormats.includes(item) && !processedGroups.block) {
toolbar.appendChild(this.createBlockFormatDropdown());
processedGroups.block = true;
} else if (alignments.includes(item) && !processedGroups.align) {
toolbar.appendChild(this.createAlignmentDropdown());
processedGroups.align = true;
} else if (fileItems.includes(item) && !processedGroups.file) {
toolbar.appendChild(this.createGroupDropdown('File', groups.file));
processedGroups.file = true;
} else if (!blockFormats.includes(item) && !alignments.includes(item) && !fileItems.includes(item)) {
const button = this.createToolbarButton(item);
if (button) {
toolbar.appendChild(button);
}
}
}
}
return toolbar;
}
createGroupDropdown(label, items) {
const dropdown = document.createElement('div');
dropdown.className = 'editium-dropdown';
const menuId = `editium-menu-${Math.random().toString(36).substr(2, 9)}`;
const trigger = document.createElement('button');
trigger.className = 'editium-toolbar-button editium-dropdown-trigger';
trigger.type = 'button';
trigger.textContent = label;
trigger.title = label;
// ARIA attributes for accessibility
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-controls', menuId);
const menu = document.createElement('div');
menu.className = 'editium-dropdown-menu';
menu.id = menuId;
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-orientation', 'vertical');
menu.setAttribute('aria-hidden', 'true');
items.forEach((itemType, index) => {
const config = this.getButtonConfig(itemType);
if (!config) return;
const item = document.createElement('button');
item.type = 'button';
item.innerHTML = `${config.icon} <span>${config.title}</span>`;
item.setAttribute('role', 'menuitem');
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
item.onclick = (e) => {
e.preventDefault();
config.action();
this.closeDropdown();
this.focusEditor();
};
menu.appendChild(item);
});
// Add keyboard navigation to trigger
trigger.onclick = (e) => {
e.preventDefault();
this.toggleDropdown(menu, trigger);
};
trigger.onkeydown = (e) => {
this.handleDropdownTriggerKeyDown(e, menu, trigger);
};
// Add keyboard navigation to menu
this.addMenuKeyboardNavigation(menu, trigger);
dropdown.appendChild(trigger);
dropdown.appendChild(menu);
return dropdown;
}
createBlockFormatDropdown() {
const dropdown = document.createElement('div');
dropdown.className = 'editium-dropdown';
const menuId = `editium-menu-${Math.random().toString(36).substr(2, 9)}`;
const trigger = document.createElement('button');
trigger.className = 'editium-toolbar-button editium-dropdown-trigger';
trigger.type = 'button';
trigger.textContent = 'Paragraph';
trigger.title = 'Block Format';
// ARIA attributes for accessibility
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-controls', menuId);
const menu = document.createElement('div');
menu.className = 'editium-dropdown-menu';
menu.id = menuId;
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-orientation', 'vertical');
menu.setAttribute('aria-hidden', 'true');
const formats = [
{ label: 'Paragraph', value: 'p' },
{ label: 'Heading 1', value: 'h1' },
{ label: 'Heading 2', value: 'h2' },
{ label: 'Heading 3', value: 'h3' },
{ label: 'Heading 4', value: 'h4' },
{ label: 'Heading 5', value: 'h5' },
{ label: 'Heading 6', value: 'h6' },
];
formats.forEach((format, index) => {
const item = document.createElement('button');
item.type = 'button';
item.textContent = format.label;
item.setAttribute('role', 'menuitem');
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
item.onclick = (e) => {
e.preventDefault();
this.execCommand('formatBlock', `<${format.value}>`);
trigger.textContent = format.label;
this.closeDropdown();
this.focusEditor();
};
menu.appendChild(item);
});
trigger.onclick = (e) => {
e.preventDefault();
this.toggleDropdown(menu, trigger);
};
trigger.onkeydown = (e) => {
this.handleDropdownTriggerKeyDown(e, menu, trigger);
};
// Add keyboard navigation to menu
this.addMenuKeyboardNavigation(menu, trigger);
dropdown.appendChild(trigger);
dropdown.appendChild(menu);
return dropdown;
}
createAlignmentDropdown() {
const dropdown = document.createElement('div');
dropdown.className = 'editium-dropdown';
const menuId = `editium-menu-${Math.random().toString(36).substr(2, 9)}`;
const trigger = document.createElement('button');
trigger.className = 'editium-toolbar-button editium-dropdown-trigger';
trigger.type = 'button';
trigger.textContent = 'Align';
trigger.title = 'Text Alignment';
// ARIA attributes for accessibility
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-controls', menuId);
const menu = document.createElement('div');
menu.className = 'editium-dropdown-menu';
menu.id = menuId;
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-orientation', 'vertical');
menu.setAttribute('aria-hidden', 'true');
const alignments = [
{ label: 'Align Left', icon: '<i class="fa-solid fa-align-left"></i>', command: 'justifyLeft' },
{ label: 'Align Center', icon: '<i class="fa-solid fa-align-center"></i>', command: 'justifyCenter' },
{ label: 'Align Right', icon: '<i class="fa-solid fa-align-right"></i>', command: 'justifyRight' },
{ label: 'Justify', icon: '<i class="fa-solid fa-align-justify"></i>', command: 'justifyFull' },
];
alignments.forEach((align, index) => {
const item = document.createElement('button');
item.type = 'button';
item.innerHTML = `${align.icon} <span>${align.label}</span>`;
item.setAttribute('role', 'menuitem');
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
item.onclick = (e) => {
e.preventDefault();
this.execCommand(align.command);
this.closeDropdown();
this.focusEditor();
};
menu.appendChild(item);
});
trigger.onclick = (e) => {
e.preventDefault();
this.toggleDropdown(menu, trigger);
};
trigger.onkeydown = (e) => {
this.handleDropdownTriggerKeyDown(e, menu, trigger);
};
// Add keyboard navigation to menu
this.addMenuKeyboardNavigation(menu, trigger);
dropdown.appendChild(trigger);
dropdown.appendChild(menu);
return dropdown;
}
toggleDropdown(menu, trigger) {
if (this.openDropdown === menu) {
this.closeDropdown();
} else {
this.closeDropdown();
menu.classList.add('show');
this.openDropdown = menu;
this.currentDropdownTrigger = trigger;
// Update ARIA attributes
if (trigger) {
trigger.setAttribute('aria-expanded', 'true');
}
menu.setAttribute('aria-hidden', 'false');
// Focus first menu item
const firstItem = menu.querySelector('[role="menuitem"]');
if (firstItem) {
setTimeout(() => firstItem.focus(), 0);
}
}
}
closeDropdown() {
if (this.openDropdown) {
this.openDropdown.classList.remove('show');
// Update ARIA attributes
if (this.currentDropdownTrigger) {
this.currentDropdownTrigger.setAttribute('aria-expanded', 'false');
}
this.openDropdown.setAttribute('aria-hidden', 'true');
this.openDropdown = null;
this.currentDropdownTrigger = null;
}
}
// New method to handle keyboard navigation on dropdown triggers
handleDropdownTriggerKeyDown(event, menu, trigger) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.toggleDropdown(menu, trigger);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
this.toggleDropdown(menu, trigger);
} else if (event.key === 'Escape') {
event.preventDefault();
this.closeDropdown();
trigger.focus();
}
}
// New method to add keyboard navigation to menu items
addMenuKeyboardNavigation(menu, trigger) {
menu.addEventListener('keydown', (e) => {
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
const currentIndex = items.indexOf(document.activeElement);
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
if (currentIndex < items.length - 1) {
items[currentIndex + 1].focus();
this.updateMenuItemTabIndex(items, currentIndex + 1);
}
break;
case 'ArrowUp':
e.preventDefault();
if (currentIndex > 0) {
items[currentIndex - 1].focus();
this.updateMenuItemTabIndex(items, currentIndex - 1);
}
break;
case 'Home':
e.preventDefault();
items[0].focus();
this.updateMenuItemTabIndex(items, 0);
break;
case 'End':
e.preventDefault();
items[items.length - 1].focus();
this.updateMenuItemTabIndex(items, items.length - 1);
break;
case 'Escape':
e.preventDefault();
this.closeDropdown();
if (trigger) {
trigger.focus();
}
break;
case 'Enter':
case ' ':
e.preventDefault();
if (document.activeElement && document.activeElement.hasAttribute('role')) {
document.activeElement.click();
}
break;
case 'Tab':
e.preventDefault();
this.closeDropdown();
if (trigger) {
trigger.focus();
}
break;
}
});
}
// Update tabindex for roving tabindex pattern
updateMenuItemTabIndex(items, focusedIndex) {
items.forEach((item, index) => {
item.setAttribute('tabindex', index === focusedIndex ? '0' : '-1');
});
}
// New method to focus the editor
focusEditor() {
setTimeout(() => {
if (this.editor) {
this.editor.focus();
}
}, 0);
}
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
if (this.showEmojiPicker) {
this.createAndShowEmojiPicker();
} else {
this.closeEmojiPicker();
}
}
closeEmojiPicker() {
if (this._emojiBackdrop && this._emojiBackdrop.parentNode) {
this._emojiBackdrop.parentNode.removeChild(this._emojiBackdrop);
}
this._emojiBackdrop = null;
if (this.emojiPickerElement && this.emojiPickerElement.parentNode) {
this.emojiPickerElement.parentNode.removeChild(this.emojiPickerElement);
}
this.emojiPickerElement = null;
this.showEmojiPicker = false;
// Remove reposition listeners
if (this._emojiRepositionHandler) {
window.removeEventListener('scroll', this._emojiRepositionHandler, true);
window.removeEventListener('resize', this._emojiRepositionHandler);
this._emojiRepositionHandler = null;
}
}
_loadEmojiPickerScript() {
return new Promise((resolve) => {
if (customElements.get('emoji-picker')) {
resolve();
return;
}
const script = document.createElement('script');
script.type = 'module';
script.src = 'https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js';
script.onload = () => resolve();
script.onerror = () => resolve(); // fail silently
document.head.appendChild(script);
});
}
async createAndShowEmojiPicker() {
if (this.emojiPickerElement) {
this.closeEmojiPicker();
return;
}
// Save current selection so we can restore it when inserting emoji
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
this._savedRange = sel.getRangeAt(0).cloneRange();
}
// Load the web component if not already loaded
await this._loadEmojiPickerScript();
// Container for the picker
const container = document.createElement('div');
container.className = 'editium-emoji-picker';
container.style.cssText = `
position: fixed;
z-index: 10000;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background: #fff;
overflow: hidden;
`;
// Create the emoji-picker web component
const picker = document.createElement('emoji-picker');
picker.classList.add('light');
container.appendChild(picker);
// Listen for emoji selection
picker.addEventListener('emoji-click', (event) => {
this.insertEmoji(event.detail.unicode);
this.closeEmojiPicker();
});
// Find the emoji button to anchor positioning
const emojiButton = this._emojiWrapper
? this._emojiWrapper.querySelector('[data-command="emoji"]')
: null;
// Position helper: keeps the picker anchored below the button
const positionPicker = () => {
if (!emojiButton) return;
const btnRect = emojiButton.getBoundingClientRect();
container.style.top = (btnRect.bottom + 8) + 'px';
let left = btnRect.right - 350;
if (left < 8) left = 8;
container.style.left = left + 'px';
};
positionPicker();
// Backdrop to close on outside click
const backdrop = document.createElement('div');
backdrop.style.cssText = `
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 9999;
`;
backdrop.onclick = () => this.closeEmojiPicker();
document.body.appendChild(backdrop);
document.body.appendChild(container);
this._emojiBackdrop = backdrop;
this.emojiPickerElement = container;
// Re-position on scroll/resize so picker follows the button
this._emojiRepositionHandler = () => positionPicker();
window.addEventListener('scroll', this._emojiRepositionHandler, true);
window.addEventListener('resize', this._emojiRepositionHandler);
}
insertEmoji(emoji) {
this.editor.focus();
// Restore saved selection if available
if (this._savedRange) {
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(this._savedRange);
this._savedRange = null;
}
document.execCommand('insertText', false, emoji);
this.saveState();
this.triggerChange();
}
createToolbarButton(type) {
const config = this.getButtonConfig(type);
if (!config) return null;
if (config.dropdown) {
return this.createDropdownButton(type, config);
}
const button = document.createElement('button');
button.className = 'editium-toolbar-button';
button.type = 'button';
button.setAttribute('data-command', type);
button.innerHTML = config.icon;
button.title = config.title;
button.onclick = (e) => {
e.preventDefault();
config.action();
this.closeDropdown();
};
return button;
}
createDropdownButton(type, config) {
const dropdown = document.createElement('div');
dropdown.className = 'editium-dropdown';
const menuId = `editium-menu-${Math.random().toString(36).substr(2, 9)}`;
const trigger = document.createElement('button');
trigger.className = 'editium-toolbar-button editium-dropdown-trigger';
trigger.type = 'button';
trigger.innerHTML = config.icon;
trigger.title = config.title;
// ARIA attributes for accessibility
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-controls', menuId);
const menu = document.createElement('div');
menu.className = 'editium-dropdown-menu';
menu.id = menuId;
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-orientation', 'vertical');
menu.setAttribute('aria-hidden', 'true');
config.dropdown.forEach((item, index) => {
const menuItem = document.createElement('button');
menuItem.type = 'button';
menuItem.textContent = item.label;
menuItem.setAttribute('role', 'menuitem');
menuItem.setAttribute('tabindex', index === 0 ? '0' : '-1');
menuItem.onclick = (e) => {
e.preventDefault();
item.action();
this.closeDropdown();
this.focusEditor();
};
menu.appendChild(menuItem);
});
trigger.onclick = (e) => {
e.preventDefault();
this.toggleDropdown(menu, trigger);
};
trigger.onkeydown = (e) => {
this.handleDropdownTriggerKeyDown(e, menu, trigger);
};
// Add keyboard navigation to menu
this.addMenuKeyboardNavigation(menu, trigger);
dropdown.appendChild(trigger);
dropdown.appendChild(menu);
return dropdown;
}
getButtonConfig(type) {
const configs = {
'bold': { icon: '<i class="fa-solid fa-bold"></i>', title: 'Bold (Ctrl+B)', action: () => this.execCommand('bold') },
'italic': { icon: '<i class="fa-solid fa-italic"></i>', title: 'Italic (Ctrl+I)', action: () => this.execCommand('italic') },
'underline': { icon: '<i class="fa-solid fa-underline"></i>', title: 'Underline (Ctrl+U)', action: () => this.execCommand('underline') },
'strikethrough': { icon: '<i class="fa-solid fa-strikethrough"></i>', title: 'Strikethrough', action: () => this.execCommand('strikeThrough') },
'superscript': { icon: '<i class="fa-solid fa-superscript"></i>', title: 'Superscript', action: () => this.execCommand('superscript') },
'subscript': { icon: '<i class="fa-solid fa-subscript"></i>', title: 'Subscript', action: () => this.execCommand('subscript') },
'code': { icon: '<i class="fa-solid fa-code"></i>', title: 'Code', action: () => this.toggleInlineCode() },
'left': { icon: '<i class="fa-solid fa-align-left"></i>', title: 'Align Left', action: () => this.execCommand('justifyLeft') },
'center': { icon: '<i class="fa-solid fa-align-center"></i>', title: 'Align Center', action: () => this.execCommand('justifyCenter') },
'right': { icon: '<i class="fa-solid fa-align-right"></i>', title: 'Align Right', action: () => this.execCommand('justifyRight') },
'justify': { icon: '<i class="fa-solid fa-align-justify"></i>', title: 'Justify', action: () => this.execCommand('justifyFull') },
'bulleted-list': { icon: '<i class="fa-solid fa-list-ul"></i>', title: 'Bulleted List', action: () => this.execCommand('insertUnorderedList') },
'numbered-list': { icon: '<i class="fa-solid fa-list-ol"></i>', title: 'Numbered List', action: () => this.execCommand('insertOrderedList') },
'indent': { icon: '<i class="fa-solid fa-indent"></i>', title: 'Indent', action: () => this.execCommand('indent') },
'outdent': { icon: '<i class="fa-solid fa-outdent"></i>', title: 'Outdent', action: () => this.execCommand('outdent') },
'link': { icon: '<i class="fa-solid fa-link"></i>', title: 'Insert Link', action: () => this.showLinkModal() },
'image': { icon: '<i class="fa-solid fa-image"></i>', title: 'Insert Image', action: () => this.showImageModal() },
'blockquote': { icon: '<i class="fa-solid fa-quote-left"></i>', title: 'Blockquote', action: () => this.execCommand('formatBlock', '<blockquote>') },
'code-block': { icon: '<i class="fa-solid fa-file-code"></i>', title: 'Code Block', action: () => this.insertCodeBlock() },
'horizontal-rule': { icon: '<i class="fa-solid fa-minus"></i>', title: 'Horizontal Rule', action: () => this.execCommand('insertHorizontalRule') },
'table': { icon: '<i class="fa-solid fa-table"></i>', title: 'Insert Table', action: () => this.showTableModal() },
'emoji': { icon: '<i class="fa-regular fa-face-smile"></i>', title: 'Emoji Picker', action: () => this.toggleEmojiPicker() },
'text-color': { icon: '<i class="fa-solid fa-palette"></i>', title: 'Text Color', action: () => this.showColorPicker('foreColor') },
'bg-color': { icon: '<i class="fa-solid fa-fill-drip"></i>', title: 'Background Color', action: () => this.showColorPicker('hiliteColor') },
'undo': { icon: '<i class="fa-solid fa-rotate-left"></i>', title: 'Undo (Ctrl+Z)', action: () => this.undo() },
'redo': { icon: '<i class="fa-solid fa-rotate-right"></i>', title: 'Redo (Ctrl+Y)', action: () => this.redo() },
'preview': { icon: '<i class="fa-solid fa-eye"></i>', title: 'Preview', action: () => this.viewOutput('preview') },
'view-html': { icon: '<i class="fa-solid fa-code"></i>', title: 'View HTML', action: () => this.viewOutput('html') },
'view-json': { icon: '<i class="fa-solid fa-brackets-curly"></i>', title: 'View JSON', action: () => this.viewOutput('json') },
'find-replace': { icon: '<i class="fa-solid fa-magnifying-glass"></i>', title: 'Find & Replace', action: () => this.toggleFindReplace() },
'fullscreen': { icon: '<i class="fa-solid fa-expand"></i>', title: 'Toggle Fullscreen (F11)', action: () => this.toggleFullscreen() },
'import-docx': { icon: '<i class="fa-solid fa-arrow-up-from-bracket"></i>', title: 'Import Word (.docx)', action: () => this.importDocx() },
'export-docx': { icon: '<i class="fa-solid fa-arrow-down-to-bracket"></i>', title: 'Export to Word (.docx)', action: () => this.exportDocx() },
'export-pdf': { icon: '<i class="fa-solid fa-download"></i>', title: 'Export to PDF', action: () => this.exportPdf() }
};
return configs[type];
}
execCommand(command, value = null) {
document.execCommand(command, false, value);
this.editor.focus();
this.saveState();
this.triggerChange();
}
toggleInlineCode() {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const selectedText = range.toString();
if (selectedText) {
const code = document.createElement('code');
code.style.backgroundColor = '#f4f4f4';
code.style.padding = '2px 4px';
code.style.borderRadius = '3px';
code.style.fontFamily = 'monospace';
code.textContent = selectedText;
range.deleteContents();
range.insertNode(code);
this.saveState();
this.triggerChange();
}
}
showLinkModal() {
this.editor.focus();
const selection = window.getSelection();
const selectedText = selection.toString();
let savedRange = null;
if (selection.rangeCount > 0) savedRange = selection.getRangeAt(0).cloneRange();
const modal = this.createModal('Insert Link', `
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 14px; color: #333;">Link Text:</label>
<input type="text" id="link-text" value="${this.escapeHtml(selectedText)}" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px;">
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 14px; color: #333;">URL: *</label>
<input type="text" id="link-url" placeholder="https://example.com" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px;">
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 14px; color: #333;">Title (optional):</label>
<input type="text" id="link-title" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px;">
</div>
<div style="margin-bottom: 16px;">
<label style="display: inline-flex; align-items: center; font-size: 14px; color: #333; cursor: pointer;">
<input type="checkbox" id="link-target" style="margin-right: 8px;"> Open in new tab
</label>
</div>
`, () => {
const url = document.getElementById('link-url').value.trim();
const text = document.getElementById('link-text').value.trim();
const title = document.getElementById('link-title').value.trim();
const target = document.getElementById('link-target').checked;
if (!url) {
alert('URL is required');
return false;
}
try {
new URL(url);
} catch {
alert('Please enter a valid URL');
return false;
}
if (savedRange) {
this.editor.focus();
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(savedRange);
}
const link = document.createElement('a');
link.href = url;
link.textContent = text || url;
link.contentEditable = 'false';
if (title) link.title = title;
if (target) link.target = '_blank';
const sel = window.getSelection();
if (sel.rangeCount) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(link);
const space = document.createTextNode('\u00A0');
range.setStartAfter(link);
range.insertNode(space);
range.setStartAfter(space);
range.setEndAfter(space);
sel.removeAllRanges();
sel.addRange(range);
}
this.saveState();
this.triggerChange();
return true;
});
document.body.appendChild(modal);
document.getElementById('link-url').focus();
}
showLinkPopup(linkElement) {
this.selectedLink = linkElement;
this.closeLinkPopup();
const rect = linkElement.getBoundingClientRect();
this.linkPopup = document.createElement('div');
this.linkPopup.className = 'editium-link-popup';
this.linkPopup.style.cssText = `
position: fixed;
top: ${rect.bottom + window.scrollY + 5}px;
left: ${rect.left + window.scrollX}px;
background-color: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
min-width: 200px;
overflow: hidden;
z-index: 10000;
`;
this.linkPopup.innerHTML = `
<div style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; background-color: #f9fafb;">
<div style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">Link URL:</div>
<div style="font-size: 13px; color: #111827; word-break: break-all; font-family: monospace;">
${this.escapeHtml(linkElement.href)}
</div>
</div>
<button class="editium-link-popup-btn editium-link-open" style="
width: 100%;
padding: 12px 16px;
border: none;
background-color: transparent;
color: #374151;
font-size: 14px;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
font-weight: 500;
">
<svg style="width: 16px; height: 16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open Link
</button>
<button class="editium-link-popup-btn editium-link-edit" style="
width: 100%;
padding: 12px 16px;
border: none;
background-color: transparent;
color: #374151;
font-size: 14px;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
font-weight: 500;
">
<svg style="width: 16px; height: 16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit Link
</button>
<button class="editium-link-popup-btn editium-link-remove" style="
width: 100%;
padding: 12px 16px;
border: none;
border-top: 1px solid #e5e7eb;
background-color: transparent;
color: #ef4444;
font-size: 14px;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
font-weight: 500;
">
<svg style="width: 16px; height: 16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Remove Link
</button>
`;
const buttons = this.linkPopup.querySelectorAll('.editium-link-popup-btn');
buttons.forEach(btn => {
btn.addEventListener('mouseenter', () => {
if (btn.classList.contains('editium-link-remove')) {
btn.style.backgroundColor = '#fef2f2';
} else {
btn.style.backgroundColor = '#f3f4f6';
}
});
btn.addEventListener('mouseleave', () => {
btn.style.backgroundColor = 'transparent';
});
});
this.linkPopup.querySelector('.editium-link-open').addEventListener('click', () => {
window.open(linkElement.href, linkElement.target || '_self');
this.closeLinkPopup();
});
this.linkPopup.querySelector('.editium-link-edit').addEventListener('click', () => {
this.closeLinkPopup();
this.editLink(linkElement);
});
this.linkPopup.querySelector('.editium-link-remove').addEventListener('click', () => {
this.removeLink(linkElement);
this.closeLinkPopup();
});
document.body.appendChild(this.linkPopup);
}
closeLinkPopup() {
if (this.linkPopup) {
this.linkPopup.remove();
this.linkPopup = null;
}
this.selectedLink = null;
}
editLink(linkElement) {
const savedLinkElement = linkElement;
const modal = this.createModal('Edit Link', `
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 14px; color: #333;">Link Text:</label>
<input type="text" id="link-text" value="${this.escapeHtml(linkElement.textContent)}" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px;">
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 14px; color: #333;">URL: *</label>
<input type="text" id="link-url" value="${this.escapeHtml(linkElement.href)}" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px;">
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 14px; color: #333;">Title (optional):</label>
<input type="text" id="link-title" value="${this.escapeHtml(linkElement.title || '')}" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px;">
</div>
<div style="margin-bottom: 16px;">
<label style="display: inline-flex; align-items: center; font-size: 14px; color: #333; cursor: pointer;">
<input type="checkbox" id="link-target" ${linkElement.target === '_blank' ? 'checked' : ''} style="margin-right: 8px;"> Open in new tab
</label>
</div>
`, () => {
const url = document.getElementById('link-url').value.trim();
const text = document.getElementById('link-text').value.trim();
const title = document.getElementById('link-title').value.trim();
const target = document.getElementById('link-target').checked;
if (!url) {
alert('URL is required');
return false;
}
try {
var parsedUrl = new URL(url, window.location.origin);
} catch {
alert('Please enter a valid URL');
return false;
}
// Only allow http, https, and mailto schemes for safety
const allowedSchemes = ['http:', 'https:', 'mailto:'];
if (!allowedSchemes.includes(parsedUrl.protocol)) {
alert('Only http, https, and mailto links are allowed for security reasons.');
return false;
}
savedLinkElement.href = url;
savedLinkElement.textContent = text || url;
savedLinkElement.title = title;
savedLinkElement.target = target ? '_blank' : '';
savedLinkElement.contentEditable = 'false';
this.saveState();
this.triggerChange();
return true;
});
document.body.appendChild(modal);
document.getElementById('link-url').focus();
}
removeLink(linkElement) {
const textNode = document.createTextNode(linkElement.textContent);
linkElement.parentNode.replaceChild(textNode, linkElement);
this.saveState();
this.triggerChange();
}
showImageModal() {
this.editor.focus();
const selection = window.getSelection();
let savedRange = null;
if (selection.rangeCount > 0) {
savedRange = selection.getRangeAt(0).cloneRange();
}
const modal = this.createModal('Insert Image', `
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 14px; color: #333;">Image URL:</label>
<input type="text" id="image-url" placeholder="https://example.com/image.jpg" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px;">
</div>
${this.onImageUpload ? `
<div style="margin-bottom: 16px; text-align: center;">
<div style="color: #666; margin-bottom: 8px;">- OR -</div>
<input type="file" id="image-file" accept="image/*" style="display: block; margin: 0 auto;">
</div>
` : ''}
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 14px; color: #333;">Alt Text:</label>
<input type="text" id="image-alt" placeholder="Image description" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px;">
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 14px; color: #333;">Width (optional):</label>
<input type="number" id="image-width" placeholder="e.g., 400" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px;">
</div>
`, async () => {
let url = document.getElementById('image-url').value.trim();
const alt = document.getElementById('image-alt').value.trim();
const width = document.getElementById('image-width').value.trim();
const fileInput = document.getElementById('image-file');
if (fileInput && fileInput.files.length > 0) {
if (this.onImageUpload) {
try {
url = await this.onImageUpload(fileInput.files[0]);
} catch (error) {
alert('Failed to upload image');
return false;
}
}
}
if (!url) {
alert('Image URL is required');
return false;
}
this.insertImage(url, alt || 'Image', width ? parseInt(width) : null, savedRange);
return true;
});
document.body.appendChild(modal);
document.getElementById('image-url').focus();
}
insertImage(url, alt = 'Image', width = null, savedRange = null) {
if (savedRange) {
this.editor.focus();
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(savedRange);
} else {
this.editor.focus();
}
const imageWrapper = document.createElement('div');
imageWrapper.className = 'editium-image-wrapper align-left';
imageWrapper.contentEditable = 'false';
imageWrapper.style.textAlign = 'left';
const imageContainer = document.createElement('div');
imageContainer.style.position = 'relative';
imageContainer.style.display = 'inline-block';
const img = document.createElement('img');
img.src = url;
img.alt = alt;
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.display = 'block';
img.style.marginLeft = '0';
img.style.marginRight = 'auto';
img.className = 'resizable';
img.draggable = false;
if (width) {
img.style.width = width + 'px';
}
const toolbar = this.createImageToolbar(imageWrapper, img);
imageContainer.appendChild(img);
imageContainer.appendChild(toolbar);
imageWrapper.appendChild(imageContainer);
this.makeImageResizable(img);
const selection = window.getSelection();
let inserted = false;
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
try {
range.insertNode(imageWrapper);
const newPara = document.createElement('p');
newPara.innerHTML = '<br>';
if (imageWrapper.nextSibling) {
imageWrapper.parentNode.insertBefore(newPara, imageWrapper.nextSibling);
} else {
imageWrapper.parentNode.appendChild(newPara);
}
range.setStart(newPara, 0);
range.setEnd(newPara, 0);
selection.removeAllRanges();
selection.addRange(range);
inserted = true;
} catch (e) {
console.error('Error inserting image at cursor:', e);
}
}
if (!inserted) {
this.editor.appendChild(imageWrapper);
const newPara = document.createElement('p');
newPara.innerHTML = '<br>';
this.editor.appendChild(newPara);
const range = document.createRange();
range.setStart(newPara, 0);
range.setEnd(newPara, 0);
selection.removeAllRanges();
selection.addRange(range);
}
this.saveState();
this.triggerChange();
}
createImageToolbar(wrapper, img) {
const toolbar = document.createElement('div');
toolbar.className = 'editium-image-toolbar';
const alignmentGroup = document.createElement('div');
alignmentGroup.className = 'editium-image-toolbar-group';
const alignments = [
{ value: 'left', label: '⬅', title: 'Align left' },
{ value: 'center', label: '↔', title: 'Align center' },
{ value: 'right', label: '➡', title: 'Align right' }
];
alignments.forEach(align => {
const btn = document.createElement('button');
btn.textContent = align.label;
btn.title = align.title;
btn.className = align.value === 'left' ? 'active' : '';
btn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
this.changeImageAlignment(wrapper, align.value);
alignmentGroup.querySelectorAll('button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
};
alignmentGroup.appendChild(btn);
});
toolbar.appendChild(alignmentGroup);
const actionGroup = document.createElement('div');
actionGroup.className = 'editium-image-toolbar-group';
const removeBtn = document.createElement('button');
removeBtn.innerHTML = '<i class="fa-solid fa-trash"></i>';
removeBtn.title = 'Remove Image';
removeBtn.style.color = '#dc3545';
removeBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (confirm('Remove this image?')) {
wrapper.remove();
this.saveState();
this.triggerChange();
}
};
actionGroup.appendChild(removeBtn);
toolbar.appendChild(actionGroup);
return toolbar;
}
changeImageAlignment(wrapper, alignment) {
wrapper.classList.remove('align-left', 'align-center', 'align-right');
wrapper.classList.add(`align-${alignment}`);
const container = wrapper.querySelector('div[style*="position: relative"]');
const img = wrapper.querySelector('img');
if (container && img) {
if (alignment === 'left') {
wrapper.style.textAlign = 'left';
img.style.marginLeft = '0';
img.style.marginRight = 'auto';
} else if (alignment === 'center') {
wrapper.style.textAlign = 'center';
img.style.marginLeft = 'auto';
img.style.marginRight = 'auto';
} else if (alignment === 'right') {
wrapper.style.textAlign = 'right';
img.style.marginLeft = 'auto';
img.style.marginRight = '0';
}
}
this.saveState();
this.triggerChange();
}
makeImageResizable(img) {
let isResizing = false;
let startX, startWidth;
const startResize = (e) => {
e.preventDefault();
e.stopPropagation();
isResizing = true;
startX = e.clientX || e.touches[0].clientX;
startWidth = img.offsetWidth;
img.classList.add('resizing');
document.addEventListener('mousemove', resize