I recently decided to make my own Rich Text Editor, I was generally satisfied with the version via execCommand, but I wanted to take on my own implementation because of the work with.
Here is the code of my currently working version:
import React, { useRef } from "react";
import "./TextEditor_v2.css";
const ModernTextEditor = () => {
const editorRef = useRef();
const applyStyle = (style, value) => {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
if (range.collapsed) return;
const selectedContent = range.cloneContents();
const fragment = document.createDocumentFragment();
const mergeStyles = (existingStyles, newStyles) => {
const tempSpan = document.createElement("span");
tempSpan.style.cssText = existingStyles;
if (style === "bold")
tempSpan.style.fontWeight = value ? "bold" : "normal";
if (style === "italic")
tempSpan.style.fontStyle = value ? "italic" : "normal";
if (style === "color") tempSpan.style.color = value;
if (style === "fontSize") tempSpan.style.fontSize = value + "px";
if (style === "fontFamily") tempSpan.style.fontFamily = value;
return tempSpan.style.cssText;
};
const processNode = (node, parentStyles = "") => {
if (node.nodeType === Node.TEXT_NODE) {
const span = document.createElement("span");
span.style.cssText = mergeStyles(parentStyles, style);
span.textContent = node.textContent;
return span;
} else if (node.nodeType === Node.ELEMENT_NODE) {
const combinedStyles = mergeStyles(node.style.cssText, style);
const span = document.createElement("span");
span.style.cssText = combinedStyles;
Array.from(node.childNodes).forEach((child) => {
span.appendChild(processNode(child, combinedStyles));
});
return span;
}
return node;
};
Array.from(selectedContent.childNodes).forEach((node) => {
fragment.appendChild(processNode(node));
});
range.deleteContents();
range.insertNode(fragment);
selection.removeAllRanges();
};
const handleCommand = (command, value = null) => {
applyStyle(command, value);
};
return (
<div className="text-editor">
<div className="toolbar">
<button onClick={() => handleCommand("bold", true)}>Bold</button>
<button onClick={() => handleCommand("italic", true)}>Italic</button>
<input
type="color"
onChange={(e) => handleCommand("color", e.target.value)}
/>
<select
onChange={(e) => handleCommand("fontSize", e.target.value)}
defaultValue="16"
>
{[12, 14, 16, 18, 20, 24, 28].map((size) => (
<option key={size} value={size}>
{size}px
</option>
))}
</select>
<select
onChange={(e) => handleCommand("fontFamily", e.target.value)}
defaultValue="Arial"
>
{["Arial", "Georgia", "Courier New", "Verdana"].map((font) => (
<option key={font} value={font}>
{font}
</option>
))}
</select>
</div>
<div
ref={editorRef}
className="editor"
contentEditable={true}
suppressContentEditableWarning={true}
></div>
</div>
);
};
export default ModernTextEditor;
And styles:
`.text-editor {
border: 1px solid #ccc;
border-radius: 5px;
width: 80%;
margin: 20px auto;
padding: 10px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.editor {
min-height: 200px;
border: 1px solid #eee;
padding: 10px;
font-family: Arial, sans-serif;
font-size: 16px;
outline: none;
cursor: text;
}`
At the moment, everything works through a div with contentEditable that can be filled with text, and when I apply some styles, the text is wrapped in a span with the necessary styles. I really didn’t like the fact that in the end I came up with a solution in which applying a new style creates a new span in which the old one is wrapped. I tried to do something about it and wrote another version of the method:
const applyStyle = (style, value) => {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
if (range.collapsed) return; // No text selected
const selectedText = range.extractContents();
const parentNode = range.startContainer.parentNode;
const processNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
// Create a span to wrap this text node with the new style
const span = document.createElement("span");
// Preserve existing styles
if (parentNode.nodeName === "SPAN") {
span.style.cssText = parentNode.style.cssText;
}
// Apply the new style
if (style === "bold") span.style.fontWeight = value ? "bold" : "normal";
if (style === "italic")
span.style.fontStyle = value ? "italic" : "normal";
if (style === "color") span.style.color = value;
if (style === "fontSize") span.style.fontSize = value + "px";
if (style === "fontFamily") span.style.fontFamily = value;
span.textContent = node.textContent;
return span;
} else if (node.nodeName === "SPAN") {
// If the node is a span, recursively process its children
const newSpan = document.createElement("span");
newSpan.style.cssText = node.style.cssText;
// Apply the new style to this span
if (style === "bold")
newSpan.style.fontWeight = value ? "bold" : "normal";
if (style === "italic")
newSpan.style.fontStyle = value ? "italic" : "normal";
if (style === "color") newSpan.style.color = value;
if (style === "fontSize") newSpan.style.fontSize = value + "px";
if (style === "fontFamily") newSpan.style.fontFamily = value;
Array.from(node.childNodes).forEach((child) => {
newSpan.appendChild(processNode(child));
});
return newSpan;
}
return node;
};
const newNodes = [];
Array.from(selectedText.childNodes).forEach((node) => {
newNodes.push(processNode(node));
});
// Split existing span into 3 parts if selection is in the middle
const startOffset = range.startOffset;
const endOffset = range.endOffset;
if (parentNode.nodeName === "SPAN") {
const textContent = parentNode.textContent;
const beforeText = textContent.slice(0, startOffset);
const afterText = textContent.slice(endOffset);
if (beforeText) {
const beforeSpan = document.createElement("span");
beforeSpan.style.cssText = parentNode.style.cssText;
beforeSpan.textContent = beforeText;
parentNode.parentNode.insertBefore(beforeSpan, parentNode);
}
if (afterText) {
const afterSpan = document.createElement("span");
afterSpan.style.cssText = parentNode.style.cssText;
afterSpan.textContent = afterText;
parentNode.parentNode.insertBefore(afterSpan, parentNode.nextSibling);
}
parentNode.parentNode.removeChild(parentNode);
}
// Insert the new styled nodes
newNodes.forEach((node) => {
range.insertNode(node);
});
// Clean up selection
selection.removeAllRanges();
};
This version of the method provides many cases and is able to split span elements into separate ones if I wanted to apply a style only to part of the text, but there is a problem that I cannot make a mechanism for applying styles to several selected span elements at the same time, for example, write 2 separate words and give each of them styles and then try to change the color of both of them by selecting them at the same time, it just turns into a mess.