You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

富文本编辑器中为多个独立span元素批量应用样式的问题

富文本编辑器中为多个独立span元素批量应用样式的问题

最近我打算自己实现一个富文本编辑器,一开始用execCommand的版本我还挺满意,但还是想自己从头实现一遍,折腾一下。

下面是我目前能正常运行的版本代码:

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;

对应的样式文件:

.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;
}

这个版本是基于contentEditable的div来实现的,应用样式时会把选中的文本包裹在带有对应样式的span标签里。但我特别不爽的是,每次应用新样式都会嵌套一层新的span,所以我尝试重写了applyStyle方法:

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();
};

这个新版本的方法能处理不少场景,比如当我只选中一个span中间的部分时,它会把这个span拆分成三个独立的span(选中部分前后保留原样式,中间应用新样式)。但现在遇到了一个棘手的问题:当我选中多个独立的span元素(比如先写两个单词,分别给它们设置不同的样式,然后同时选中这两个单词想修改它们的颜色),结果就变得一团糟,样式完全乱了套。

备注:内容来源于stack exchange,提问作者Araders

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.14 17:09:37