React中contenteditable div重渲染后如何恢复文本选中状态
解决方案
核心原因
你之前存储的原生Range对象绑定了旧DOM节点的引用,当setHtmlContent触发重渲染时,contentEditable容器内的DOM会被dangerouslySetInnerHTML完全替换,旧Range关联的节点已经被销毁,所以直接恢复旧Range无效。
改造方案
我们需要将选区信息序列化为不带DOM引用的普通对象存储,重渲染后再根据序列化信息在新DOM上重建选区:
- 新增选区序列化/反序列化工具函数
- 存储序列化后的选区信息,而非原生Range对象
- 重渲染完成后根据序列化信息重建选区
完整修改后代码
import { useEffect, useState, useRef } from "react"; import "./styles.css"; export default function App() { const [htmlContent, setHtmlContent] = useState("<p>Hello World!</p>"); const [savedSelection, setSavedSelection] = useState(null); const editorRef = useRef(null); // 序列化选区:转换为可存储的普通对象 const serializeRange = (range, root) => { const getNodePath = (node) => { const path = []; while (node && node !== root) { const parent = node.parentNode; if (!parent) break; const index = Array.from(parent.childNodes).indexOf(node); path.unshift(index); node = parent; } return path; }; return { startPath: getNodePath(range.startContainer), startOffset: range.startOffset, endPath: getNodePath(range.endContainer), endOffset: range.endOffset, }; }; // 反序列化选区:根据存储的信息在新DOM上重建Range const deserializeRange = (serialized, root) => { const getNodeFromPath = (path) => { let node = root; for (const index of path) { node = node.childNodes[index]; if (!node) return null; } return node; }; const range = document.createRange(); const startNode = getNodeFromPath(serialized.startPath); const endNode = getNodeFromPath(serialized.endPath); if (!startNode || !endNode) return null; range.setStart(startNode, serialized.startOffset); range.setEnd(endNode, serialized.endOffset); return range; }; // 重渲染后恢复选区 useEffect(() => { if (savedSelection && editorRef.current) { const range = deserializeRange(savedSelection, editorRef.current); if (range) { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } } }, [htmlContent, savedSelection]); const storeSelectionRange = () => { const sel = window.getSelection(); if (sel.toString().length && editorRef.current) { const range = sel.getRangeAt(0); const serialized = serializeRange(range, editorRef.current); setSavedSelection(serialized); } }; // 点击按钮时阻止默认行为,避免编辑器失焦 const onButtonMouseDown = (e) => { e.preventDefault(); }; const onClick = () => { if (savedSelection && editorRef.current) { // 先重建当前选区 const range = deserializeRange(savedSelection, editorRef.current); if (!range) return; const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); // 包裹选中文本为加粗span const newNode = document.createElement("span"); newNode.className = "text-bold"; newNode.textContent = sel.toString(); range.deleteContents(); range.insertNode(newNode); // 更新内容,同时保存新的选区(选中刚插入的粗体文本) const newHtml = editorRef.current.innerHTML; const newRange = document.createRange(); newRange.selectNodeContents(newNode); setSavedSelection(serializeRange(newRange, editorRef.current)); setHtmlContent(newHtml); } }; return ( <div className="container"> <div className="editor"> <div ref={editorRef} className="content" contentEditable="true" dangerouslySetInnerHTML={{ __html: htmlContent }} onBlur={storeSelectionRange} ></div> <button className="btn" onMouseDown={onButtonMouseDown} onClick={onClick}> Bold </button> </div> <div className="selected-text">{htmlContent}</div> </div> ); }
对应styles.css补充样式:
.text-bold { font-weight: 700; } .content { border: 1px solid #ccc; padding: 8px; min-height: 100px; margin-bottom: 8px; }
关键优化点
- 用
useRef绑定编辑器节点,避免每次用querySelector查找 - 点击按钮时通过
onMouseDown阻止默认行为,避免编辑器主动失焦 - 加粗完成后自动保存新的选区为刚插入的粗体文本,方便后续连续加样式
内容的提问来源于stack exchange,提问作者BlackMath
相关产品推荐
相关产品推荐

