UNPKG

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
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