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

React富文本编辑器contenteditable div光标无法移动问题求助

React富文本编辑器光标异常问题修复

问题现象

开发React富文本编辑器时遇到光标异常:每输入一个字符,插入点无法跟随输入位置,始终停留在左侧,字符被插入到光标左侧;即使手动将光标移到单词末尾后尝试输入,光标仍会回到左侧。

问题原因

  1. DOM重置导致光标丢失:同时使用dangerouslySetInnerHTML和contentEditable时,每次输入触发setContent更新state,组件重新渲染,编辑器的innerHTML会被强制重置为state中的值,光标位置被强制恢复到初始状态。
  2. 不必要的focus干扰:onInput事件中调用e.target.focus()会干扰浏览器默认的光标定位逻辑。

解决方案

  • 移除dangerouslySetInnerHTML的实时强制渲染,让contentEditable元素自主管理内部内容
  • 仅在需要时同步编辑器内容到state(比如保存、格式操作后)
  • 删除干扰光标的多余focus()调用

修改后的代码

React组件代码

import React from "react";
import { useState, useRef } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
  faBold,
  faCode,
  faFileCode,
  faFilm,
  faImage,
  faItalic,
  faLink,
  faList,
  faListOl,
  faQuoteRight,
  faStrikethrough,
  faUnderline,
} from "@fortawesome/free-solid-svg-icons";
import "./textEditor.css";

const TextEditor = () => {
  const [content, setContent] = useState("");
  const editorRef = useRef(null);
  const [isBold, setIsBold] = useState(false);
  const [isItalic, setIsItalic] = useState(false);
  const [isUnderline, setIsUnderline] = useState(false);
  const [isStrikeThrough, setStrikeThrough] = useState(false);
  const [isBlockquote, setIsBlockquote] = useState(false);
  const [isHeading1, setIsHeading1] = useState(false);
  const [isHeading2, setIsHeading2] = useState(false);
  const [isOrderedList, setIsOrderedList] = useState(false);
  const [isUnorderedList, setIsUnorderedList] = useState(false);
  const [isInlineCode, setIsInlineCode] = useState(false);
  const [isCodeBlock, setIsCodeBlock] = useState(false);

  // 同步编辑器内容到state(按需调用)
  const syncContentToState = () => {
    if (editorRef.current) {
      setContent(editorRef.current.innerHTML);
    }
  };

  const handleFormat = (style, value = null) => {
    switch (style) {
      case "bold":
        setIsBold(!isBold);
        break;
      case "italic":
        setIsItalic(!isItalic);
        break;
      case "underline":
        setIsUnderline(!isUnderline);
        break;
      case "strikeThrough":
        setStrikeThrough(!isStrikeThrough);
        break;
      case "formatBlock":
        if (value === "<blockquote>") {
          setIsBlockquote(!isBlockquote);
          value = isBlockquote ? "<p>" : "<blockquote>";
        } else if (value === "<h1>") {
          setIsHeading1(!isHeading1);
          value = isHeading1 ? "<p>" : "<h1>";
        } else if (value === "<h2>") {
          setIsHeading2(!isHeading2);
          value = isHeading2 ? "<p>" : "<h2>";
        } else if (value === "<pre>") {
          setIsCodeBlock(!isCodeBlock);
          value = isCodeBlock ? "<p>" : "<pre>";
        }
        break;
      case "insertOrderedList":
        setIsOrderedList(!isOrderedList);
        break;
      case "insertUnorderedList":
        setIsUnorderedList(!isUnorderedList);
        break;
      case "code":
        setIsInlineCode(!isInlineCode);
        break;

      case "createLink":
        const url = prompt("Enter the link URL:");
        if (url) {
          document.execCommand("createLink", false, url);
        }
        break;

      default:
        break;
    }

    document.execCommand(style, false, value);
    syncContentToState();
  };

  return (
    <div className="editor-wrapper">
      <div className="editor">
        <div className="toolbar">
          <button className="editor-btn" onClick={() => handleFormat("bold")}>
            <FontAwesomeIcon icon={faBold} />
          </button>

          <button
            className="editor-btn"
            onClick={() => handleFormat("underline")}
          >
            <FontAwesomeIcon icon={faUnderline} />
          </button>

          <button className="editor-btn" onClick={() => handleFormat("italic")}>
            <FontAwesomeIcon icon={faItalic} />
          </button>

          <button
            className="editor-btn"
            onClick={() => handleFormat("strikeThrough")}
          >
            <FontAwesomeIcon icon={faStrikethrough} />
          </button>

          <button
            className="editor-btn"
            onClick={() => handleFormat("formatBlock", "<blockquote>")}
          >
            <FontAwesomeIcon icon={faQuoteRight} />
          </button>

          <button
            className="editor-btn"
            style={{ fontWeight: "bold" }}
            onClick={() => handleFormat("formatBlock", "<h1>")}
          >
            H1
          </button>

          <button
            className="editor-btn"
            style={{ fontWeight: "bold" }}
            onClick={() => handleFormat("formatBlock", "<h2>")}
          >
            H2
          </button>

          <button
            className="editor-btn"
            onClick={() => handleFormat("insertUnorderedList")}
          >
            <FontAwesomeIcon icon={faList} />
          </button>

          <button
            className="editor-btn"
            onClick={() => handleFormat("insertOrderedList")}
          >
            <FontAwesomeIcon icon={faListOl} />
          </button>

          <button className="editor-btn" onClick={() => handleFormat("code")}>
            <FontAwesomeIcon icon={faCode} />
          </button>

          <button
            className="editor-btn"
            onClick={() => handleFormat("formatBlock", "<pre>")}
          >
            <FontAwesomeIcon icon={faFileCode} />
          </button>

          <button
            className="editor-btn"
            onClick={() => handleFormat("createLink")}
          >
            <FontAwesomeIcon icon={faLink} />
          </button>
          <button
            className="editor-btn"
            onClick={() =>
              handleFormat("insertImage", prompt("Enter image URL:"))
            }
          >
            <FontAwesomeIcon icon={faImage} />
          </button>

          <button
            className="editor-btn"
            onClick={() =>
              handleFormat(
                "insertHTML",
                '<iframe width="560" height="315" src="https://www.youtube.com/embed/VIDEO_ID" frameborder="0" allowfullscreen></iframe>'
              )
            }
          >
            <FontAwesomeIcon icon={faFilm} />
          </button>
        </div>
        <div
          ref={editorRef}
          className="text-editor"
          contentEditable="true"
          dir="ltr"
          onInput={syncContentToState}
          dangerouslySetInnerHTML={{ __html: content }}
        />
        <br />
      </div>
    </div>
  );
};

export default TextEditor;

CSS代码(无需修改)

.editor-wrapper {
  width: 100%;
  height: 500px;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.editor {
  width: 70%;
}

.toolbar {
  border: 1px solid black;
}
.text-editor {
  border: 1px solid black;
  padding-left: 2px;
  min-height: 100px;
  resize: vertical;
  overflow: auto;
}

.editor:focus {
  border: none;
}

.editor-btn {
  background: none;
  border: none;
  cursor: pointer;
  color: #414141;
}

.editor-btn:hover {
  color: blue;
}

.text-editor blockquote {
  border-left: 4px solid #414141;
  margin-right: 2px;
}

.text-editor pre {
  background-color: #dbdbdb;
  word-wrap: break-word;
}

.text-editor code {
  background-color: #dbdbdb;
}

关键修改说明

  1. 使用useRef绑定编辑器:通过editorRef直接操作DOM元素,避免React重新渲染时重置编辑器内容。
  2. 按需同步内容:仅在输入和格式操作后同步内容到state,不再强制实时覆盖编辑器innerHTML。
  3. 移除干扰逻辑:删除onInput中的e.target.focus(),让浏览器自主管理光标位置。

内容的提问来源于stack exchange,提问作者Lakshit Tiwari

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.02 16:24:58