Apply style to separate span elements in Rich Text Editor

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.