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
JavaScript
/**
* 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>';