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,644 lines (1,402 loc) 108 kB
/** * Editium - Vanilla JavaScript Rich Text Editor (Bundled Version) * Version: 1.0.1 | License: MIT * Single file bundle - includes CSS and Font Awesome icons */ (function() { 'use strict'; function injectStyles() { if (typeof document === 'undefined' || document.getElementById('editium-styles')) return; const styleElement = document.createElement('style'); styleElement.id = 'editium-styles'; styleElement.textContent = `@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"); /** * Editium - Vanilla JavaScript Rich Text Editor Styles * Matches the React version UI */ /* Main container */ .editium-wrapper { border: 1px solid #ccc; border-radius: 4px; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } /* Fullscreen mode */ .editium-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100vw; height: 100vh; z-index: 9999; border-radius: 0; margin: 0; } /* Block body scroll when in fullscreen mode */ body.editium-fullscreen-active { overflow: hidden; } /* Toolbar */ .editium-toolbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 12px; background-color: #f8f9fa; border-bottom: 1px solid #ccc; border-radius: 4px 4px 0 0; align-items: center; } .editium-toolbar-button { background-color: transparent; border: none; border-radius: 3px; padding: 5px 8px; cursor: pointer; font-size: 14px; font-weight: 400; color: #222f3e; transition: background-color 0.1s ease; display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-width: 28px; min-height: 28px; line-height: 1; white-space: nowrap; } .editium-toolbar-button i { font-size: 14px; width: 14px; height: 14px; display: inline-flex; align-items: center; justify-content: center; } .editium-toolbar-button:hover { background-color: #e9ecef; } .editium-toolbar-button:active, .editium-toolbar-button.active { background-color: #dee2e6; } .editium-toolbar-button strong, .editium-toolbar-button em, .editium-toolbar-button u, .editium-toolbar-button s { pointer-events: none; } .editium-toolbar-separator { width: 1px; height: 24px; background-color: #ccc; margin: 0 4px; align-self: center; } /* Dropdown */ .editium-dropdown { position: relative; display: inline-block; } .editium-dropdown-trigger { display: inline-flex; align-items: center; gap: 4px; } .editium-dropdown-trigger::after { content: '▼'; font-size: 8px; margin-left: 2px; opacity: 0.6; } .editium-dropdown-menu { display: none; position: absolute; top: 100%; left: 0; background-color: #ffffff; border: 1px solid #ccc; border-radius: 3px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); margin-top: 4px; min-width: 180px; z-index: 9999; padding: 4px 0; overflow: hidden; } .editium-dropdown-menu.show { display: block; } .editium-dropdown-menu button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px 16px; border: none; background: none; text-align: left; cursor: pointer; font-size: 14px; font-weight: 400; color: #222f3e; transition: background-color 0.1s ease; border-radius: 0; } .editium-dropdown-menu button i { font-size: 14px; width: 16px; display: inline-flex; align-items: center; justify-content: center; } .editium-dropdown-menu button:hover { background-color: #e7f4ff; } .editium-dropdown-menu button.active { background-color: #e7f4ff; } .editium-dropdown-menu button i { width: 16px; text-align: center; margin-right: 4px; } .editium-dropdown-menu button span { flex: 1; text-align: left; } /* Editor container */ .editium-editor-container { position: relative; flex: 1; display: flex; flex-direction: column; overflow: hidden; } /* Fullscreen editor container should allow scrolling */ .editium-fullscreen .editium-editor-container { overflow: auto; } /* Editor area */ .editium-editor { flex: 1; padding: 16px; /* Height, min-height, and max-height are set via inline styles from options */ outline: none; font-size: 14px; line-height: 1.6; color: #000; overflow-y: auto; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } /* In fullscreen mode, ensure editor takes full available space */ .editium-fullscreen .editium-editor { height: 100% !important; min-height: unset !important; max-height: unset !important; } .editium-editor:empty:before { content: attr(data-placeholder); color: #999; pointer-events: none; position: absolute; } /* Content styles - Match React version exactly */ .editium-editor h1, .editium-editor h2, .editium-editor h3, .editium-editor h4, .editium-editor h5, .editium-editor h6 { margin: 0; font-weight: normal; } .editium-editor h1 { font-size: 2em; font-weight: bold; } .editium-editor h2 { font-size: 1.5em; font-weight: bold; } .editium-editor h3 { font-size: 1.25em; font-weight: bold; } .editium-editor h4 { font-size: 1.1em; font-weight: bold; } .editium-editor h5 { font-size: 1em; font-weight: bold; } .editium-editor h6 { font-size: 0.9em; font-weight: bold; } .editium-editor p { margin: 0; font-weight: normal; } .editium-editor blockquote { margin: 1em 0; padding-left: 1em; border-left: 4px solid #dee2e6; color: #6c757d; font-style: italic; } .editium-editor code { background-color: #f4f4f4; padding: 2px 4px; border-radius: 3px; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; } .editium-editor pre { background-color: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; margin: 1em 0; } .editium-editor pre code { background: none; padding: 0; } .editium-editor ul, .editium-editor ol { margin: 1em 0; padding-left: 2em; } .editium-editor li { margin: 0.5em 0; } .editium-editor a { color: #007bff; text-decoration: underline; cursor: pointer; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; position: relative; padding: 2px 4px; border-radius: 3px; transition: all 0.15s ease; display: inline-block; } .editium-editor a:hover { color: #0056b3; background-color: rgba(0, 123, 255, 0.1); } /* Visual hint for clickable links */ .editium-editor a::after { content: ''; position: absolute; bottom: 0px; left: 0; right: 0; height: 2px; background-color: transparent; transition: background-color 0.2s ease; } .editium-editor a:hover::after { background-color: rgba(0, 123, 255, 0.4); } .editium-editor img { max-width: 100%; height: auto; display: block; margin: 10px 0; } .editium-editor img.resizable { cursor: nwse-resize; border: 2px solid transparent; transition: border-color 0.2s ease; position: relative; } .editium-editor img.resizable:hover, .editium-editor img.resizable:focus { border-color: #007bff; outline: none; } .editium-editor img.resizing { border-color: #007bff; opacity: 0.8; } /* Image wrapper for alignment */ .editium-image-wrapper { margin: 10px 0; display: flex; position: relative; } .editium-image-wrapper.align-left { justify-content: flex-start; } .editium-image-wrapper.align-center { justify-content: center; } .editium-image-wrapper.align-right { justify-content: flex-end; } /* Image toolbar that appears on hover/selection */ .editium-image-toolbar { position: absolute; top: 8px; right: 8px; display: none; gap: 4px; z-index: 10; } .editium-image-wrapper:hover .editium-image-toolbar, .editium-image-wrapper.selected .editium-image-toolbar { display: flex; } .editium-image-toolbar-group { display: flex; gap: 4px; background-color: #ffffff; border: 1px solid #d1d5db; border-radius: 4px; padding: 4px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } .editium-image-toolbar button { padding: 4px 8px; background-color: transparent; border: none; border-radius: 2px; color: #374151; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; min-width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; } .editium-image-toolbar button:hover { background-color: #f9fafb; } .editium-image-toolbar button.active { background-color: #e0f2fe; } .editium-editor hr { border: none; border-top: 2px solid #dee2e6; margin: 2em 0; } .editium-editor table { border-collapse: collapse; width: 100%; margin: 1em 0; } .editium-editor table th, .editium-editor table td { border: 1px solid #dee2e6; padding: 8px 12px; text-align: left; } .editium-editor table th { background-color: #f8f9fa; font-weight: 600; } .editium-editor table tr:nth-child(even) { background-color: #f8f9fa; } /* Search highlighting */ .editium-search-match { background-color: #ffeb3b; color: #000000; padding: 2px 4px; border-radius: 2px; } .editium-search-current { background-color: #ff9800; color: #ffffff; padding: 2px 4px; border-radius: 2px; font-weight: 600; } /* Find & Replace Panel */ .editium-find-replace { background-color: #f9fafb; border: 1px solid #ccc; border-top: none; border-radius: 0 0 4px 4px; padding: 16px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } .editium-find-replace-row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; } .editium-find-replace-row:last-child { margin-bottom: 0; } .editium-find-input, .editium-replace-input { flex: 1; padding: 6px 10px; border: 1px solid #ced4da; border-radius: 4px; font-size: 14px; outline: none; } .editium-find-input:focus, .editium-replace-input:focus { border-color: #80bdff; box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); } .editium-find-replace button { padding: 6px 12px; border: 1px solid #ced4da; border-radius: 4px; background-color: #ffffff; cursor: pointer; font-size: 14px; transition: background-color 0.15s ease; } .editium-find-replace button:hover { background-color: #e9ecef; } .editium-match-count { font-size: 14px; color: #6c757d; white-space: nowrap; } .editium-btn-prev, .editium-btn-next { min-width: 32px; } .editium-btn-close { background-color: transparent; border: none; font-size: 18px; color: #6c757d; cursor: pointer; padding: 0 8px; } .editium-btn-close:hover { color: #dc3545; } /* Word count */ .editium-word-count { padding: 8px 16px; background-color: #f8f9fa; border-top: 1px solid #ccc; border-radius: 0 0 4px 4px; font-size: 12px; color: #666; text-align: right; display: flex; justify-content: flex-end; gap: 16px; } .editium-word-count strong { color: #000; font-weight: 500; } /* Modal */ .editium-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 20px; } .editium-modal-content { background-color: #ffffff; border-radius: 8px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); max-width: 800px; max-height: 90vh; width: 100%; display: flex; flex-direction: column; overflow: hidden; } .editium-modal-content.editium-preview { max-width: 1200px; } .editium-modal-header { padding: 16px 20px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; } .editium-modal-header h3 { margin: 0; font-size: 18px; font-weight: 600; color: #222f3e; } .editium-modal-close { background: none; border: none; font-size: 24px; color: #6c757d; cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: background-color 0.15s ease; } .editium-modal-close:hover { background-color: #f8f9fa; color: #dc3545; } .editium-modal-body { flex: 1; padding: 20px; overflow: auto; } .editium-modal-body pre { background-color: #f8f9fa; padding: 15px; border-radius: 4px; overflow-x: auto; margin: 0; } .editium-modal-body code { font-family: 'Courier New', Courier, monospace; font-size: 13px; line-height: 1.5; color: #222f3e; } .editium-modal-footer { padding: 16px 20px; border-top: 1px solid #e0e0e0; display: flex; justify-content: flex-end; gap: 10px; } .editium-btn-copy { padding: 8px 16px; border: 1px solid #007bff; border-radius: 4px; background-color: #007bff; color: #ffffff; cursor: pointer; font-size: 14px; font-weight: 500; transition: background-color 0.15s ease; } .editium-btn-copy:hover { background-color: #0056b3; border-color: #0056b3; } /* Word count */ .editium-word-count { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background-color: #f8f9fa; border-top: 1px solid #ccc; font-size: 12px; color: #6c757d; border-radius: 0 0 4px 4px; } /* When only branding is shown (no stats) */ .editium-word-count:has(.editium-word-count-branding:only-child) { justify-content: flex-end; } .editium-word-count-stats { text-align: left; display: flex; gap: 16px; } .editium-word-count-branding { text-align: right; color: #6c757d; } .editium-word-count-branding .editium-brand { color: #4f88f7; font-weight: 500; text-decoration: none; cursor: pointer; transition: color 0.2s ease; } .editium-word-count-branding .editium-brand:hover { color: #3b6fd9; } /* Responsive */ @media (max-width: 768px) { .editium-toolbar { padding: 8px; gap: 2px; } .editium-toolbar-button { padding: 5px 8px; min-width: 28px; min-height: 28px; font-size: 13px; } .editium-editor { padding: 15px; font-size: 15px; } .editium-modal-content { max-width: 95%; } } /* Print styles */ @media print { .editium-toolbar, .editium-word-count, .editium-find-replace { display: none; } .editium-wrapper { border: none; } .editium-editor { padding: 0; } } `; document.head.appendChild(styleElement); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', injectStyles); } else { injectStyles(); } 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; 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', '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'], 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('View', groups.view)); 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; let processedGroups = { block: false, align: 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 (!blockFormats.includes(item) && !alignments.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 trigger = document.createElement('button'); trigger.className = 'editium-toolbar-button editium-dropdown-trigger'; trigger.type = 'button'; trigger.textContent = label; trigger.title = label; const menu = document.createElement('div'); menu.className = 'editium-dropdown-menu'; items.forEach(itemType => { 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.onclick = (e) => { e.preventDefault(); config.action(); this.closeDropdown(); }; menu.appendChild(item); }); trigger.onclick = (e) => { e.preventDefault(); this.toggleDropdown(menu); }; dropdown.appendChild(trigger); dropdown.appendChild(menu); return dropdown; } createBlockFormatDropdown() { const dropdown = document.createElement('div'); dropdown.className = 'editium-dropdown'; const trigger = document.createElement('button'); trigger.className = 'editium-toolbar-button editium-dropdown-trigger'; trigger.type = 'button'; trigger.textContent = 'Paragraph'; trigger.title = 'Block Format'; const menu = document.createElement('div'); menu.className = 'editium-dropdown-menu'; 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 => { const item = document.createElement('button'); item.type = 'button'; item.textContent = format.label; item.onclick = (e) => { e.preventDefault(); this.execCommand('formatBlock', `<${format.value}>`); trigger.textContent = format.label; this.closeDropdown(); }; menu.appendChild(item); }); trigger.onclick = (e) => { e.preventDefault(); this.toggleDropdown(menu); }; dropdown.appendChild(trigger); dropdown.appendChild(menu); return dropdown; } createAlignmentDropdown() { const dropdown = document.createElement('div'); dropdown.className = 'editium-dropdown'; const trigger = document.createElement('button'); trigger.className = 'editium-toolbar-button editium-dropdown-trigger'; trigger.type = 'button'; trigger.textContent = 'Align'; trigger.title = 'Text Alignment'; const menu = document.createElement('div'); menu.className = 'editium-dropdown-menu'; 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 => { const item = document.createElement('button'); item.type = 'button'; item.innerHTML = `${align.icon} <span>${align.label}</span>`; item.onclick = (e) => { e.preventDefault(); this.execCommand(align.command); this.closeDropdown(); }; menu.appendChild(item); }); trigger.onclick = (e) => { e.preventDefault(); this.toggleDropdown(menu); }; dropdown.appendChild(trigger); dropdown.appendChild(menu); return dropdown; } toggleDropdown(menu) { if (this.openDropdown === menu) { this.closeDropdown(); } else { this.closeDropdown(); menu.classList.add('show'); this.openDropdown = menu; } } closeDropdown() { if (this.openDropdown) { this.openDropdown.classList.remove('show'); this.openDropdown = null; } } 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 trigger = document.createElement('button'); trigger.className = 'editium-toolbar-button editium-dropdown-trigger'; trigger.type = 'button'; trigger.innerHTML = config.icon; trigger.title = config.title; const menu = document.createElement('div'); menu.className = 'editium-dropdown-menu'; config.dropdown.forEach(item => { const menuItem = document.createElement('button'); menuItem.type = 'button'; menuItem.textContent = item.label; menuItem.onclick = (e) => { e.preventDefault(); item.action(); this.closeDropdown(); }; menu.appendChild(menuItem); }); trigger.onclick = (e) => { e.preventDefault(); this.toggleDropdown(menu); }; 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() }, '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() } }; 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 { new URL(url); } catch { alert('Please enter a valid URL'); 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>';