如何实现富文本编辑器[1 - 实现多节点选区标记]

888 阅读5分钟

接着上次的 如何实现富文本编辑器[0 - Range 初探] 继续更新,本次主要实现多节点 的选区,以及复杂场景下的选区

现在本篇实现的逻辑还是没有脱离 document.execCommand 的影子

极度不推荐使用 contenteditable 作为容器来实现富文本编辑器

当前页面中所有展开的选区都使用 【】 表示

先看一段 html, 光标我们用 【】 表示选区,这是比较复杂场景中的一段样式,中间的 span 可能是多个样式

<div id="editor-wrapper"  contenteditable="true">
    <p>附件【爱<span style="font-weight: bold;">迪生开发撒放假卡了撒</span>就发拉克丝的接发撒发<span>&nbsp;</span></p>
    <p>附件爱迪生开发撒放假卡了撒就发拉克丝的接发撒发</p>
    <p>附件爱迪生开发撒<span style="font-weight: bold;">放假卡了</span>撒就发拉克丝的接发撒发<span>&nbsp;</span></p>
</div>

demo

然后看看思路

先说思路

对选区进行分类,获取里边所有的 text 节点,给 text 节点加上一个 <span class="Selected"></span> 包裹。表示被选中,之后修复当前选区,应用之前的样式 bold ...., 然后在修复取消掉我们手动添加的 span.Selected 元素

首先,我们需要对选区进行分类,比如我们可以将选区分为:

  1. 单个节点的选区
  2. 多个节点的选区

单节的选区之前我们已经实现了,重点看看第二个

多个节点的选区

多个节点的选区也更加复杂,我们暂时对它进行分类为三种

  1. 开始节点和结尾节点都完整包括
        <div><b>测试文本1</b>测试文本2<em>测试文本3</em></div>
    
  2. 开始节点不完整包括,尾部节点完整包括
        <div><b>测试【文本1</b>测试文本2<em>测试文本3</em></div>
    
  3. 开始节点完整包括,结尾节点不完整包括
        <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 在展开的情况下

  1. 如果 range 的起始节点是 text 节点,并且 startOffset 不为 0
  2. 如果 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);
    }
}

最终完整代码分享

直接测试即可

demo 地址

项目地址

总结

该方法只是实现了浏览器原生的 document.execcommand 中的一小部分,关于后续如何实现 command 部分,我想,既然都可以拿到选区中具体的节点了,至于给这个元素加粗还是斜体,还是替换成其他节点,超链接应该是没有问题了

后续接着更新 command 模块

(有时间就更新的快)

PS: 码字不易,转载请注明出处