接着上次的 如何实现富文本编辑器[0 - Range 初探] 继续更新,本次主要实现多节点 的选区,以及复杂场景下的选区
现在本篇实现的逻辑还是没有脱离 document.execCommand
的影子
极度不推荐使用 contenteditable
作为容器来实现富文本编辑器
当前页面中所有展开的选区都使用 【】
表示
先看一段 html, 光标我们用 【】
表示选区,这是比较复杂场景中的一段样式,中间的 span 可能是多个样式
<div id="editor-wrapper" contenteditable="true">
<p>附件【爱<span style="font-weight: bold;">迪生开发撒放假卡了撒</span>就发拉克丝的接发撒发<span> </span></p>
<p>附件爱迪生开发撒放假卡了撒就发拉克丝的接发撒发</p>
<p>附件爱迪生开发撒<span style="font-weight: bold;">放假卡了</span>撒就发拉克丝的接发撒发<span> </span>】</p>
</div>
然后看看思路
先说思路
对选区进行分类,获取里边所有的 text 节点,给 text 节点加上一个 <span class="Selected"></span>
包裹。表示被选中,之后修复当前选区,应用之前的样式 bold
...., 然后在修复取消掉我们手动添加的 span.Selected
元素
首先,我们需要对选区进行分类,比如我们可以将选区分为:
- 单个节点的选区
- 多个节点的选区
单节的选区之前我们已经实现了,重点看看第二个
多个节点的选区
多个节点的选区也更加复杂,我们暂时对它进行分类为三种
- 开始节点和结尾节点都完整包括
<div>【<b>测试文本1</b>测试文本2<em>测试文本3</em>】</div>
- 开始节点不完整包括,尾部节点完整包括
<div><b>测试【文本1</b>测试文本2<em>测试文本3</em>】</div>
- 开始节点完整包括,结尾节点不完整包括
<div>【<b>测试文本1</b>测试文本2<em>测试文】本3</em></div>
然后我们需要做一些准备工作
如何知道节点被包含
具有相同的父级节点,在 range 范围开始节点之后,range 范围结束节点之前,那么这个节点就定义被包含,可以简单的定义为 此处代码包含 ts 实现,方便理解
如何获得节点的长度,分为几种情况,[text, comment]
和 [doctype], [element]
export function getNodeLength(node: Node) {
switch (node.nodeType) {
case Node.PROCESSING_INSTRUCTION_NODE:
case Node.DOCUMENT_TYPE_NODE:
return 0;
case Node.TEXT_NODE:
case Node.COMMENT_NODE:
return node.length;
default:
return node.childNodes.length;
}
}
如何获得节点的位置, 通过 compareDocumentPosition
来判断
function getPosition(nodeA: Node, offsetA: number, nodeB: Node, offsetB: number): string {
if (nodeA == nodeB) {
if (offsetA == offsetB) return 'equal'
if (offsetA < offsetB) return 'before'
if (offsetA > offsetB) return 'after'
}
if (nodeB.compareDocumentPosition(nodeA) & Node.DOCUMENT_POSITION_FOLLOWING) {
const pos = getPosition(nodeB, offsetB, nodeA, offsetA);
if (pos == 'before') return 'after'
if (pos == 'after') 'before'
}
if (nodeB.compareDocumentPosition(nodeA) & Node.DOCUMENT_POSITION_CONTAINS) {
let child = nodeB;
while (child.parentNode != nodeA) child = child.parentNode
if (getNodeIndex(child) < offsetA) return 'after';
}
return 'before';
}
获得最远的祖先节点
function getFurthestAncestor(node: Node) {
let root = node
while (root.parentNode) root = root.parentNode
return root;
}
这样,这个节点是否被 range 包含,就可以判断了 有相同的父节点,在 range 范围开始节点之后,range 范围结束节点之前,那么这个节点就定义被包含
function isContained(node: Node, range: Range) {
const startPosition = getPosition(node, 0, range.startContainer, range.startOffset);
const endPosition = getPosition(node, getNodeLength(node), range.endContainer, range.endOffset);
return getFurthestAncestor(node) == getFurthestAncestor(range.startContainer)
&& startPosition == 'after'
&& endPosition == 'before';
}
function isDescendant(descendant, ancestor) {
return ancestor
&& descendant
&& Boolean(ancestor.compareDocumentPosition(descendant) & Node.DOCUMENT_POSITION_CONTAINED_BY)
}
// 相应的,我们还需要对 range 首尾的子节点进行操作,进而判断是否被有效包含
function isEffectivelyContained(node: Node, range: Range) {
if (range.collapsed) return false;
if (isContained(node, range)) return true;
if (node == range.startContainer
&& node.nodeType == Node.TEXT_NODE
&& getNodeLength(node) != range.startOffset) {
return true;
}
if (node == range.endContainer
&& node.nodeType == Node.TEXT_NODE
&& range.endOffset != 0) {
return true;
}
if (node.hasChildNodes()
&& [].every.call(node.childNodes, (child: Node) => isEffectivelyContained(child, range))
&& (!isDescendant(range.startContainer, node)
|| range.startContainer.nodeType != Node.TEXT_NODE
|| range.startOffset == 0)
&& (!isDescendant(range.endContainer, node)
|| range.endContainer.nodeType != Node.TEXT_NODE
|| range.endOffset == getNodeLength(range.endContainer)
)
) {
return true;
}
return false;
}
获得所有被包含的节点
由于需要的准备工作太多,我们且看最重要的方法, 注释已经写好
// 比较节点位置
function isBefore(node1, node2) {
return Boolean(node1.compareDocumentPosition(node2) & Node.DOCUMENT_POSITION_FOLLOWING)
}
function getAllEffectivelyContainedNodes(range: Range, condition?: Function): NodeList[] {
if (typeof condition == 'undefined') {
condition = function() { return true };
}
// 获得开始节点的最顶层被包裹在选区范围内的节点
let node = range.startContainer;
while (isEffectivelyContained(node.parentNode, range)) {
node = node.parentNode;
}
// 获得选区结束的下个节点
let stop = nextNodeDescendants(range.endContainer);
// 开始对节点进行读取,进行树深度前序遍历得到选中的 list
const nodeList = [];
while (isBefore(node, stop)) {
// 判断是否当前节点被有效包含,因为会存在范围溢出的情况
// condition 对当前节点进行判断,默认会选中所有节点
if (isEffectivelyContained(node, range)
&& condition(node)) {
nodeList.push(node);
}
node = nextNode(node);
}
return nodeList;
}
既然知道如何选区获得到元素了,我们接下来实现如何修正选区
修正选区
思路
思路,如果选区Range 在展开的情况下
- 如果 range 的起始节点是 text 节点,并且 startOffset 不为 0
- 如果 range 的结束节点是 text 节点,并且 endOffset 不为 当前 text 节点的长度
我们需要对上述选区进行修复,使其首或者尾部的节点断开,使其首部或者尾部变成自由的 text 节点
类似
<div>我是要被【选中的文本<b>我是加粗的文本</b>】</div>
中出现的 我是要被【选中的文本
文本节点会被拆分为两个 [我是要被][选中的文本]
查看代码示例
// 实现步骤
const { ownDoc, ownWin, options: { selector } } = {
ownDoc: document,
ownWin: document.defaultView,
options: { selector: '#editor-wrapper' },
}
const proxyNode = ownDoc.querySelector(selector)
const { tag, props } = {
tag: 'span',
props: {
class: 'Selected'
}
}
const cache = { selected: [] }
const getSelection = () => {
return ownWin.getSelection() || ownDoc.getSelection()
}
const getActiveRange = () => {
if (cache.activeRange) cache.oldRange = cache.activeRange
const activeSelection = getSelection()
return activeSelection.rangeCount > 0
&& (cache.activeRange = activeSelection.getRangeAt(0))
|| cache.activeRange
}
// 修复选区
const correctRange = (range) => {
cache.oldRange = range.cloneRange()
// 修正开始节点是 Text 节点时候,要对文字进行断开处理
if (range.startContainer.nodeType === Node.TEXT_NODE
&& range.startOffset !== 0
&& range.startOffset !== getNodeLength(range.startContainer)) {
const newActiveRange = ownDoc.createRange()
let newNode;
if (range.startContainer.isEqualNode(range.endContainer)) {
let newEndOffset = range.endOffset - range.startOffset;
newNode = (range.startContainer).splitText(range.startOffset);
newActiveRange.setEnd(newNode, newEndOffset);
range.setEnd(newNode, newEndOffset);
} else {
newNode = (range.startContainer).splitText(range.startOffset);
}
// 更新选区
newActiveRange.setStart(newNode, 0);
getSelection().removeAllRanges();
getSelection().addRange(newActiveRange);
getActiveRange().setStart(newNode, 0);
}
// 修正结尾节点是 Text 节点时候,要对文字进行断开处理
if (range.endContainer.nodeType === Node.TEXT_NODE
&& range.endOffset !== 0
&& range.endOffset !== getNodeLength(range.endContainer)) {
// 对尾部的 text 节点进行拆分
const activeRange = range;
const newStart = [activeRange.startContainer, activeRange.startOffset];
const newEnd = [activeRange.endContainer, activeRange.endOffset];
(activeRange.endContainer).splitText(activeRange.endOffset);
activeRange.setStart(newStart[0], newStart[1]);
activeRange.setEnd(newEnd[0], newEnd[1]);
// 更新选区
getSelection().removeAllRanges();
getSelection().addRange(activeRange);
}
}
最终完整代码分享
直接测试即可
总结
该方法只是实现了浏览器原生的 document.execcommand 中的一小部分,关于后续如何实现 command 部分,我想,既然都可以拿到选区中具体的节点了,至于给这个元素加粗还是斜体,还是替换成其他节点,超链接应该是没有问题了
后续接着更新 command 模块
(有时间就更新的快)
PS: 码字不易,转载请注明出处