需求
1、文案可编辑,能实时将输入的{{xx}}转化为tag
2、用户点击位置即光标定位处
3、可插入标签,需要在插入前记录光标位置
4、支持选中区域(即蓝标)直接替换为tag
看到需求第一眼,想到用div和inpput进行切换,但这个满足不了2,所以首先这个需求肯定不会是两个 标签切换,只能一个标签承担展示和编辑的功能,标签既能展示也能编辑,想到了html属性contentEditable,这可以实现1,2,需求3,4涉及光标和选区,利用selection对象和splitText api可实现3,4
contentEditable
contenteditable是一个枚举属性,表示元素是否可被用户编辑,如果没给出该属性或设置了无效的属性值,则其默认值继承自父元素:即,如果父元素可编辑,该子元素也可编辑
Selection
Selection对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生,Selection 对象所对应的是用户所选择的 ranges,俗称拖蓝,检查或修改的 Selection 对象,使用window.getSelection()
选区、范围
在 Web 中,选区指Selection,范围指Range,选区概念更大一点,一个选区中可能有多个范围,也就是可以从Selection中获取Range,Selection和Range都提供了很多属性和方法,可以让我们对选区进行增删改等操作
// 我是范围
const selection = document.getSelection();
// 我是选区
const range = selection.getRangeAt(0);
// 我是元素
const container = document.querySelector('.xxx');
遇到的一些问题及解决
匹配以xx开头,xx结束的正则表达式,正则表达式中插入动态数据
/开头.*?结尾/g
需求需要匹配以{{开头,}}结尾的字符串,正则表达式为/{{.*?}}/g
const edit = () => {
const currentValue = textRef.current.innerText;
let matchReg = /{{.*?}}/g;
const matchText = currentValue.match(matchReg);
if (matchText) {
const tagText = matchText[0].replace("{{", "").replace("}}", "");
textRef.current.innerHTML = textRef.current.innerHTML.replace(
matchText,
`<span contentEditable='false' style="margin-right:10px;padding:10px;border:1px solid #ccc;cursor: pointer;">${tagText}</span>`
);
textRef.current.blur();
}
};
有时正则表达式中的数据是动态的,在js中不能直接使用常规的字符串拼接方式来拼接,它必须使用eval来进行拼接,需要注意的是,用eval拼接字符时,对于 “\” 符号需要多加一个转义符,例如“\s” 需要改成 “\\s”,多加一个 “\” 字符
let matchReg = eval('/'+startContainer.textContent+'.*?'+endContainer.textContent+'/g')
原生js向后添加兄弟元素
原生js只有insertBefore api,可以利用insertBefore和nextSibling实现insertAfter
const insertAfter = (newNode, curNode) => {
curNode.parentNode.insertBefore(newNode, curNode.nextSibling);
};
当前元素为最后一个元素时,nextSibling为null,insertBefore会在父元素的最后添加新元素,仍旧生效
nextSibling和nextElementSibling的区别,nextSibling() 返回元素节点之后的兄弟节点(包括文本节点、注释节点),nextElementSibling() 只返回元素节点之后的兄弟元素节点(不包括文本节点、注释节点)
下述代码中first的nextSibling是“节点2”,nextElementSibling是<p>元素3</p>
<div>
<p id="first">元素1</p>
节点2
<p>元素3</p>
</div>
替换dom
function replaceWith(node, node2) {
var parent = node.parentNode;
if (parent) {
parent.replaceChild(node2, node);
}
}
range对象中属性详解
- startContainer:返回包含
Range开始的节点 - endContainer:返回包含
Range结束的节点 - startOffset:返回一个数字,表示
Range在startContainer中的起始位置 - endOffset:返回一个表示
Range终点在endContainer中的位置的数字 - commonAncestorContainer:返回完整包含
startContainer和endContainer的、最深一级的节点 注意:startContainer、endContainer遇标签中断
splitText(offset)
将节点分成指定偏移量处的两个节点,将树中的两个节点保持为兄弟节点,拆分后,当前节点包含到指定偏移点的所有内容,新创建的同类型节点包含剩余文本。
此方法将节点按偏移量分为左右两个节点,注意此方法会改变节点,调用后,节点变为左侧部分,如果调用同时赋值给变量,此变量值为右侧部分
node文本节点3456
let a = node.splitText(2)
console.log(node)//内容为34的文本节点
console.log(a)//内容为56的文本节点
取文本节点的内容
nodeValue属性获取该文本节点的文本内容
实现(存在相同文本替换问题)
import logo from "./logo.svg";
import "./App.css";
import { Select, Input, Tag } from "antd";
import { useRef, useState } from "react";
function App() {
const textRef = useRef(null);
const [lastEditRange, setLastEditRange] = useState(null);
const edit = () => {
const currentValue = textRef.current.innerText;
let matchReg = /{{.*?}}/g;
const matchText = currentValue.match(matchReg);
if (matchText) {
const tagText = matchText[0].replace("{{", "").replace("}}", "");
textRef.current.innerHTML = textRef.current.innerHTML.replace(
matchText,
`<span id="${
Math.random() * 1000000
}" contentEditable='false' style="margin-right:10px;padding:10px;border:1px solid #ccc;cursor: pointer;">${tagText}</span>`
);
textRef.current.blur();
}
};
const insertAfter = (newNode, curNode) => {
curNode.parentNode.insertBefore(newNode, curNode.nextSibling);
};
const getCuscor = () => {
const range = getSelection().getRangeAt(0);
setLastEditRange(range);
console.log("object", range);
};
function replaceWith(node, node2) {
var parent = node.parentNode;
if (parent) {
parent.replaceChild(node2, node);
}
}
function insert(value) {
const tagEle = `<span id='${value}' contentEditable='false' style="padding:10px;border:1px solid #ccc";cursor: pointer;>${value}</span>`;
const tagEle1 = document.createElement("span");
tagEle1.style.cssText =
"padding:10px;border:1px solid #ccc;cursor: pointer;";
tagEle1.innerHTML = value;
tagEle1.contentEditable = false;
const range = lastEditRange.cloneRange();
let startContainer = range.startContainer,
endContainer = range.endContainer,
endOffset = range.endOffset,
startOffset = range.startOffset,
commonAncestorContainer = range.commonAncestorContainer;
let node = startContainer;
if (node.nodeType === 1) {
startOffset === 0 ? node.insertBefore(tagEle1, node.firstChild) : node.appendChild(tagEle1)
}
(node === endContainer) && (node.length > endOffset) && (node.splitText(endOffset))
if (startOffset) {
node = node.splitText(startOffset);
if (endContainer === startContainer) {
endContainer = node;
endOffset -= startOffset;
}
startContainer = node;
startOffset = 0;
}
// 蓝标之间有标签
if(startContainer!==endContainer&&commonAncestorContainer.nodeName==='DIV'){
let matchReg = eval('/'+startContainer.textContent+'.*?'+endContainer.textContent+'/g')
const con = textRef.current.innerHTML.match(matchReg)
// 取startContainer和endContainer蓝标部分
startContainer.splitText(startOffset)
endContainer = endContainer.splitText(endOffset)
const t1 = startContainer.nodeValue + tagEle + endContainer.nodeValue
textRef.current.innerHTML = textRef.current.innerHTML.replace(con[0],t1)
}
replaceWith(node, tagEle1);
}
return (
<div className="App">
<Select
defaultValue="lucy"
style={{
width: 120,
}}
onChange={insert}
options={[
{
value: "jack",
label: "Jack",
},
{
value: "lucy",
label: "Lucy",
},
{
value: "disabled",
label: "Disabled",
},
{
value: "Yiminghe",
label: "yiminghe",
},
]}
/>
<div
contentEditable="true"
style={{
border: "1px solid #000",
width: "800px",
height: "400px",
padding: "20px",
}}
onInput={edit}
onBlur={getCuscor}
ref={textRef}
id="contentEditableDiv"
></div>
</div>
);
}
export default App;
execCommand解决方案
import logo from "./logo.svg";
import "./App.css";
import { Select, Input, Tag } from "antd";
import { useRef, useState } from "react";
import Slot from './components/Slot'
function App() {
const textRef = useRef(null);
const [lastEditRange, setLastEditRange] = useState(null);
const edit = () => {
const currentValue = textRef.current.innerText;
let matchReg = /{{.*?}}/g;
const matchText = currentValue.match(matchReg);
console.log('dfdf',currentValue);
if (matchText) {
const tagText = matchText[0].replace("{{", "").replace("}}", "");
textRef.current.innerHTML = textRef.current.innerHTML.replace(
matchText,
`<span id="${
Math.random() * 1000000
}" contentEditable='false' style="margin-right:10px;padding:10px;border:1px solid #ccc;cursor: pointer;">${tagText}</span>`
);
// textRef.current.blur();
}
};
function test(value){
textRef.current.focus()
const selection = getSelection()
if(lastEditRange){
selection.removeAllRanges()
selection.addRange(lastEditRange)
}
const tagEle = `{{${value}}}`;
document.execCommand('insertText',false,tagEle)
}
return (
<div className="App">
<Select
defaultValue="lucy"
style={{
width: 120,
}}
onChange={test}
options={[
{
value: "jack",
label: "Jack",
},
{
value: "lucy",
label: "Lucy",
},
{
value: "disabled",
label: "Disabled",
},
{
value: "Yiminghe",
label: "yiminghe",
},
]}
/>
<div
contentEditable="true"
style={{
border: "1px solid #000",
width: "800px",
height: "400px",
padding: "20px",
}}
onInput={edit}
onBlur={getCuscor}
ref={textRef}
id="contentEditableDiv"
>
</div>
);
}
export default App;