/**
* WYSIWYG Markdown Editor
* v=2.8
* A lightweight, real-time markdown editor with live rendering and LTR-RTL support
* Usage: MarkdownEditor.init('your-div-id');
* Author: Araz Gholami @arazgholami
* Email: contact@arazgholami.com
*/
class MarkdownEditor {
constructor(element) {
this.editor = element;
this.isProcessing = false;
this.setupEventListeners();
}
setupEventListeners() {
this.editor.addEventListener('input', (e) => {
this.handleInput(e);
});
this.editor.addEventListener('keydown', (e) => {
this.handleKeyDown(e);
});
this.editor.addEventListener('keyup', (e) => this.handleKeyUp(e));
}
handleInput(e) {
if (this.isProcessing) return;
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (range.startContainer === this.editor ||
(range.startContainer.nodeType === Node.TEXT_NODE && range.startContainer.parentNode === this.editor)) {
const div = document.createElement('div');
div.setAttribute('dir', 'auto');
if (range.startContainer.nodeType === Node.TEXT_NODE) {
div.appendChild(range.startContainer.cloneNode(true));
this.editor.removeChild(range.startContainer);
} else {
div.innerHTML = '
';
}
this.editor.appendChild(div);
this.setCursorAtEnd(div);
return;
}
const textContent = range.startContainer.textContent || '';
const cursorPos = range.startOffset;
this.processMarkdown(textContent, cursorPos, range);
}
handleKeyDown(e) {
if (e.key === 'Backspace') {
this.handleBackspace(e);
} else if (e.key === 'Enter') {
e.preventDefault();
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const textContent = range.startContainer.textContent || '';
if (textContent.trim() === '---') {
this.createHorizontalRule(range.startContainer);
return;
}
if (e.shiftKey) {
let currentElement = range.startContainer;
if (currentElement.nodeType === Node.TEXT_NODE) {
currentElement = currentElement.parentElement;
}
if (currentElement.tagName === 'BLOCKQUOTE') {
const br = document.createElement('br');
if (range.startContainer.nodeType === Node.TEXT_NODE) {
const text = range.startContainer.textContent;
const beforeText = text.substring(0, range.startOffset);
const afterText = text.substring(range.startOffset);
const beforeNode = document.createTextNode(beforeText);
const afterNode = document.createTextNode(afterText);
const parent = range.startContainer.parentNode;
parent.replaceChild(beforeNode, range.startContainer);
parent.insertBefore(br, beforeNode.nextSibling);
const nbsp = document.createTextNode('\u00A0');
parent.insertBefore(nbsp, br.nextSibling);
parent.insertBefore(afterNode, nbsp.nextSibling);
range.setStart(afterNode, 0);
range.setEnd(afterNode, 0);
selection.removeAllRanges();
selection.addRange(range);
} else {
currentElement.appendChild(br);
const nbsp = document.createTextNode('\u00A0');
currentElement.appendChild(nbsp);
this.setCursorAfter(nbsp);
}
return;
}
}
// Find the closest list item by traversing up the DOM
let currentElement = range.startContainer;
if (currentElement.nodeType === Node.TEXT_NODE) {
currentElement = currentElement.parentElement;
}
let listItem = null;
let temp = currentElement;
while (temp && temp !== this.editor) {
if (temp.tagName === 'LI') {
listItem = temp;
break;
}
temp = temp.parentElement;
}
// Handle checkbox items
const checkboxContainer = this.findCheckboxContainer(currentElement);
if (checkboxContainer) {
const checkboxText = this.getCheckboxText(checkboxContainer);
if (checkboxText.trim() === '') {
const div = document.createElement('div');
div.setAttribute('dir', 'auto');
div.innerHTML = '
';
if (checkboxContainer.nextSibling) {
checkboxContainer.parentNode.insertBefore(div, checkboxContainer.nextSibling);
} else {
checkboxContainer.parentNode.appendChild(div);
}
checkboxContainer.parentNode.removeChild(checkboxContainer);
this.setCursorAtEnd(div);
return;
} else {
this.createNewCheckboxItem(checkboxContainer);
return;
}
}
// Handle list items (both UL and OL)
if (listItem) {
// Get the text content of the list item, excluding any nested elements
const listItemText = this.getListItemTextContent(listItem);
if (listItemText.trim() === '') {
// Exit the list - create a new div
const div = document.createElement('div');
div.setAttribute('dir', 'auto');
div.innerHTML = '
';
const list = listItem.parentNode;
if (list.nextSibling) {
list.parentNode.insertBefore(div, list.nextSibling);
} else {
list.parentNode.appendChild(div);
}
list.removeChild(listItem);
if (list.children.length === 0) {
list.parentNode.removeChild(list);
}
this.setCursorAtEnd(div);
return;
} else {
// Create a new list item
const newLi = document.createElement('li');
newLi.setAttribute('dir', 'auto');
// Handle splitting text if cursor is in the middle
if (range.startContainer.nodeType === Node.TEXT_NODE) {
const textNode = range.startContainer;
const offset = range.startOffset;
const beforeText = textNode.textContent.substring(0, offset);
const afterText = textNode.textContent.substring(offset);
// Update current list item with text before cursor
textNode.textContent = beforeText;
// Add remaining text to new list item
if (afterText.length > 0) {
newLi.textContent = afterText;
} else {
newLi.innerHTML = '
';
}
} else {
newLi.innerHTML = '
';
}
const list = listItem.parentNode;
if (listItem.nextSibling) {
list.insertBefore(newLi, listItem.nextSibling);
} else {
list.appendChild(newLi);
}
this.setCursorAtStart(newLi);
return;
}
}
// Handle regular text - create new line
if (range.startContainer.nodeType === Node.TEXT_NODE) {
const textNode = range.startContainer;
const offset = range.startOffset;
const beforeText = textNode.textContent.substring(0, offset);
const afterText = textNode.textContent.substring(offset);
const newDiv = document.createElement('div');
newDiv.setAttribute('dir', 'auto');
if (afterText.length > 0) {
newDiv.textContent = afterText;
} else {
newDiv.innerHTML = '
';
}
let parentBlock = textNode.parentNode;
while (parentBlock && parentBlock.parentNode !== this.editor) {
parentBlock = parentBlock.parentNode;
}
if (parentBlock && parentBlock.parentNode === this.editor) {
if (parentBlock.nextSibling) {
this.editor.insertBefore(newDiv, parentBlock.nextSibling);
} else {
this.editor.appendChild(newDiv);
}
} else {
this.editor.appendChild(newDiv);
}
textNode.textContent = beforeText;
this.setCursorAtStart(newDiv);
return;
}
// Default case - create new div
const div = document.createElement('div');
div.setAttribute('dir', 'auto');
div.innerHTML = '
';
let container = range.startContainer;
while (container && container !== this.editor && container.parentNode !== this.editor) {
container = container.parentNode;
}
if (container === this.editor) {
this.editor.appendChild(div);
} else if (container && container.parentNode === this.editor) {
if (container.nextSibling) {
this.editor.insertBefore(div, container.nextSibling);
} else {
this.editor.appendChild(div);
}
} else {
this.editor.appendChild(div);
}
this.setCursorAtEnd(div);
}
}
findCheckboxContainer(element) {
let current = element;
while (current && current !== this.editor) {
if (current.nodeType === Node.ELEMENT_NODE) {
const checkbox = current.querySelector('input[type="checkbox"]');
if (checkbox && current.parentNode === this.editor) {
return current;
}
}
current = current.parentNode;
}
return null;
}
getCheckboxText(container) {
const textNodes = [];
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
textNodes.push(node.textContent);
}
return textNodes.join('');
}
createNewCheckboxItem(currentContainer) {
const div = document.createElement('div');
div.setAttribute('dir', 'auto');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
const textNode = document.createTextNode('');
div.appendChild(checkbox);
div.appendChild(textNode);
if (currentContainer.nextSibling) {
currentContainer.parentNode.insertBefore(div, currentContainer.nextSibling);
} else {
currentContainer.parentNode.appendChild(div);
}
this.setCursorAfter(checkbox);
}
handleKeyUp(e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
this.handleArrowEscape();
}
}
processMarkdown(text, cursorPos, range) {
this.isProcessing = true;
const patterns = [
{ regex: /`([^`]+)`/, handler: (match) => this.createInlineCode(text, match, range.startContainer), minPos: (match) => text.indexOf(match[0]) + match[0].length },
{ regex: /^-\s\[x\]\s(.*)/, handler: (match) => this.createCheckbox(match[1], range.startContainer, true), minPos: 6 },
{ regex: /^-\s\[\s?\]\s(.*)/, handler: (match) => this.createCheckbox(match[1], range.startContainer, false), minPos: 5 },
{ regex: /^(#{1,6})\s(.*)$/, handler: (match) => this.replaceWithElement(`h${match[1].length}`, match[2], range.startContainer), minPos: (match) => match[1].length + 1 },
{ regex: /^>\s(.+)$/, handler: (match) => this.createBlockquote(match[1], range.startContainer), minPos: 2 },
{ regex: /^---\s*$/, handler: (match) => this.createHorizontalRule(range.startContainer), minPos: 3 },
{ regex: /!\[([^\]]*)\]\(([^)]+)\)/, handler: (match) => this.createImage(match[1], match[2], text, range.startContainer), minPos: (match) => text.indexOf(match[0]) + match[0].length },
{ regex: /\[([^\]]+)\]\(([^)]+)\)/, handler: (match) => this.createLink(match[1], match[2], text, range.startContainer), minPos: (match) => text.indexOf(match[0]) + match[0].length },
{ regex: /\*\*(.*?)\*\*/, handler: (match) => this.replaceInlineMarkdown(text, match, 'strong', range.startContainer), minPos: (match) => text.indexOf(match[0]) + match[0].length },
{ regex: /(? this.replaceInlineMarkdown(text, match, 'em', range.startContainer), minPos: (match) => text.indexOf(match[0]) + match[0].length },
{ regex: /__(.+?)__/, handler: (match) => this.replaceInlineMarkdown(text, match, 'u', range.startContainer), minPos: (match) => text.indexOf(match[0]) + match[0].length },
{ regex: /^(\d+)\.\s(.+)$/, handler: (match) => this.createOrderedListItem(match[2], range.startContainer), minPos: (match) => match[1].length + 2 },
{ regex: /^-\s(?!\[)(.+)$/, handler: (match) => this.createListItem(match[1], range.startContainer), minPos: 2 }
];
for (const pattern of patterns) {
const match = text.match(pattern.regex);
if (match) {
const minPos = typeof pattern.minPos === 'function' ? pattern.minPos(match) : pattern.minPos;
if (cursorPos > minPos) {
pattern.handler(match);
this.isProcessing = false;
return;
}
}
}
this.isProcessing = false;
}
replaceWithElement(tagName, content, textNode) {
const element = document.createElement(tagName);
element.textContent = content;
const parent = textNode.parentNode;
parent.replaceChild(element, textNode);
this.setCursorAtEnd(element);
}
createBlockquote(content, textNode) {
const blockquote = document.createElement('blockquote');
blockquote.setAttribute('dir', 'auto');
blockquote.textContent = content;
const parent = textNode.parentNode;
parent.replaceChild(blockquote, textNode);
this.setCursorAtEnd(blockquote);
}
createInlineCode(text, match, textNode) {
const code = document.createElement('code');
code.setAttribute('dir', 'auto');
code.textContent = match[1];
this.insertElementWithText(code, text, match[0], textNode);
}
replaceInlineMarkdown(text, match, tagName, textNode) {
const element = document.createElement(tagName);
element.setAttribute('dir', 'auto');
element.textContent = match[1];
this.insertElementWithText(element, text, match[0], textNode);
}
insertElementWithText(element, text, matchText, textNode) {
const beforeText = text.substring(0, text.indexOf(matchText));
const afterText = text.substring(text.indexOf(matchText) + matchText.length);
const parent = textNode.parentNode;
if (beforeText) {
const beforeNode = document.createTextNode(beforeText);
parent.insertBefore(beforeNode, textNode);
}
parent.insertBefore(element, textNode);
if (afterText) {
const afterNode = document.createTextNode(afterText);
parent.insertBefore(afterNode, textNode);
}
parent.removeChild(textNode);
this.setCursorAfter(element);
}
createListItem(content, textNode) {
const li = document.createElement('li');
li.setAttribute('dir', 'auto');
li.textContent = content;
const parent = textNode.parentNode;
let ul = null;
if (parent.previousElementSibling && parent.previousElementSibling.tagName === 'UL') {
ul = parent.previousElementSibling;
}
if (!ul) {
ul = document.createElement('ul');
ul.setAttribute('dir', 'auto');
parent.parentNode.insertBefore(ul, parent);
}
ul.appendChild(li);
parent.parentNode.removeChild(parent);
this.setCursorAtEnd(li);
}
createOrderedListItem(content, textNode) {
const li = document.createElement('li');
li.setAttribute('dir', 'auto');
li.textContent = content;
const parent = textNode.parentNode;
let ol = null;
if (parent.previousElementSibling && parent.previousElementSibling.tagName === 'OL') {
ol = parent.previousElementSibling;
}
if (!ol) {
ol = document.createElement('ol');
ol.setAttribute('dir', 'auto');
parent.parentNode.insertBefore(ol, parent);
}
ol.appendChild(li);
parent.parentNode.removeChild(parent);
this.setCursorAtEnd(li);
}
createCheckbox(content, textNode, checked = false) {
const checkbox = document.createElement('input');
checkbox.setAttribute('dir', 'auto');
checkbox.type = 'checkbox';
if (checked) {
checkbox.setAttribute('checked', '');
checkbox.checked = true;
}
const labelText = document.createTextNode(content);
const parent = textNode.parentNode;
parent.insertBefore(checkbox, textNode);
parent.insertBefore(labelText, textNode);
parent.removeChild(textNode);
this.setCursorAfter(labelText);
}
createLink(text, url, fullText, textNode) {
const link = document.createElement('a');
link.href = url;
link.textContent = text;
link.target = '_blank';
const match = fullText.match(/\[([^\]]+)\]\(([^)]+)\)/);
this.insertElementWithText(link, fullText, match[0], textNode);
}
createImage(alt, src, fullText, textNode) {
const img = document.createElement('img');
img.src = src;
img.alt = alt;
img.setAttribute('data-draggable', '');
const match = fullText.match(/!\[([^\]]*)\]\(([^)]+)\)/);
this.insertElementWithText(img, fullText, match[0], textNode);
initDraggableImages(editor);
}
createHorizontalRule(textNode) {
const hr = document.createElement('hr');
const parent = textNode.parentNode;
parent.replaceChild(hr, textNode);
const div = document.createElement('div');
div.setAttribute('dir', 'auto');
div.innerHTML = '
';
parent.insertBefore(div, hr.nextSibling);
this.setCursorAtEnd(div);
}
handleBackspace(e) {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (range.startContainer.nodeType === Node.TEXT_NODE) {
const element = range.startContainer.parentElement;
const textContent = range.startContainer.textContent;
if (range.startOffset === textContent.length && this.isStyledElement(element)) {
e.preventDefault();
this.convertToPlain(element);
return;
}
} else if (range.startContainer.nodeType === Node.ELEMENT_NODE) {
const element = range.startContainer;
if (this.isStyledElement(element)) {
const textNode = element.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE && range.startOffset === textNode.textContent.length) {
e.preventDefault();
this.convertToPlain(element);
return;
}
}
const prevElement = range.startContainer.childNodes[range.startOffset - 1];
if (prevElement && this.isStyledElement(prevElement)) {
e.preventDefault();
this.convertToMarkdown(prevElement);
return;
}
}
}
handleArrowEscape() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const element = range.startContainer.parentElement;
if (this.isStyledElement(element) && range.startOffset === range.startContainer.textContent.length) {
this.setCursorAfter(element);
}
}
isStyledElement(element) {
return ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'STRONG', 'EM', 'U', 'CODE', 'LI', 'BLOCKQUOTE', 'A', 'IMG', 'HR'].includes(element.tagName);
}
convertToPlain(element) {
const text = element.textContent;
const plain = text;
if (element.tagName === 'LI') {
const parentList = element.parentElement;
const div = document.createElement('div');
div.setAttribute('dir', 'auto');
div.textContent = plain;
if (parentList.nextSibling) {
parentList.parentNode.insertBefore(div, parentList.nextSibling);
} else {
parentList.parentNode.appendChild(div);
}
parentList.removeChild(element);
if (parentList.children.length === 0) {
parentList.parentNode.removeChild(parentList);
}
this.setCursorAtEnd(div.childNodes[0] || div);
return;
}
const textNode = document.createTextNode(plain);
element.parentNode.replaceChild(textNode, element);
this.setCursorAtEnd(textNode);
}
setCursorAtEnd(element) {
const range = document.createRange();
const selection = window.getSelection();
if (element.nodeType === Node.TEXT_NODE) {
range.setStart(element, element.textContent.length);
} else {
range.selectNodeContents(element);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
}
setCursorAfter(element) {
const range = document.createRange();
const selection = window.getSelection();
range.setStartAfter(element);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
setCursorAtStart(element) {
const range = document.createRange();
const selection = window.getSelection();
if (element.nodeType === Node.TEXT_NODE) {
range.setStart(element, 0);
} else {
range.selectNodeContents(element);
range.collapse(true);
}
selection.removeAllRanges();
selection.addRange(range);
}
static init(elementId, options = {}) {
const element = document.getElementById(elementId);
if (!element) {
console.error(`Element with id "${elementId}" not found`);
return null;
}
element.setAttribute('contenteditable', 'true');
element.setAttribute('spellcheck', 'false');
element.setAttribute('dir', 'auto');
if (options.placeholder) {
element.setAttribute('placeholder', options.placeholder);
}
if (options.autofocus !== false) {
element.focus();
}
return new MarkdownEditor(element);
}
getListItemTextContent(listItem) {
// Get only the direct text content of the list item, not nested elements
let text = '';
for (let child of listItem.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
text += child.textContent;
}
}
return text;
}
}
document.addEventListener('DOMContentLoaded', () => {
const autoElements = document.querySelectorAll('[data-markdown-editor]');
autoElements.forEach(element => {
const options = {
placeholder: element.getAttribute('placeholder'),
autofocus: element.getAttribute('data-autofocus') !== 'false'
};
MarkdownEditor.init(element.id, options);
});
});
window.MarkdownEditor = MarkdownEditor;