先看如下两个效果:

效果演示
效果演示

效果演示
效果演示

  • 可以选中文字进行标记
  • 文本存在换行,标记也可以跨行
  • 标记有背景色,不太标记可以交叉

两个有较大的相似之处,因此一并分析,首先显示文字的是一个textarea,textarea里面必定是纯文本,那么背景色如何实现呢。

不饶弯子,直接看下图, 为了方便理解,我将每个标记再原本的位置上依次向右下偏移 + 50 px

叠加示意
叠加示意

  • 图中1为本身的 textarea ,用来显示文本和用户进行选择, 背景色透明。

  • 图中2~5为4个标记图层,如下特点:

    1. 他们每一个都和 textarea 有相同的文本内容, 这样通过设置相同的字体、行高等属性,可使得和文字显示完美重叠
    2. 文本颜色是透明的。 这样在重叠的基础上,文字不会显示出来不会有显示问题
    3. 标记的区域被一个原生包裹,有背景色。这样即实现了标记区域的背景色
    4. 标记图层的 z-index 比输入框低,或者使用 pointer-events: none; 使得鼠标可穿透,建议使用前者兼容性更好。

这样核心的功能就完成了,不过还有几点需要额外注意:

  1. 除字体、行高、编辑、边框等容易想到的属性之外, div 和 textarea 中默认的 white-space word-break word-wrap 的默认值是不一样的,需要统一处理一次。
  2. textarea中是纯文本,div中的内容是可以是富文本的,创建写入之前需要处理进行转义。

创建核心代码如下:

/**
 * 标记高亮构建
 * 
 * @param {string} txt 整个文本
 * @param {{from:number,to:number,id?:string,type:string}} item 当前标记对象描述
 */
function buildHightlightItem(txt, item) {
    var s = item.from;
    var e = item.end;
    var id = item.id;

    function encodeAndWrap(str) {
        return Util.htmlEscape(str).replace(/\n/g, '<br>');
    }

    if (!id) {
        id = Util.uuid();
    }

    var html = [
        encodeAndWrap(txt.substring(0, s)),
        '<span class="' + HIGH_LIGHT_CLS + '" style="background-color: ' + getBgColor(item.type) + '">',
        encodeAndWrap(txt.substring(s, e)),
        '</span>',
        encodeAndWrap(txt.substring(e))
    ].join('');

    var $div = $('<div>')
        .attr({
            class: HIGH_LIGHT_WRAP_CLS,
            'data-id': id
        })
        .html(html);
    $div.appendTo($textWrap);
}

选中区域变化的监听,可通过文本框的 select 时间来进行处理,dom 上的 selectionStartselectionEnd 即为选中区域在字符串中的开始和结束位置。

$markupText.on('select', function (e) {
    var target = e.target;
    var from = target.selectionStart;
    var end = target.selectionEnd;
    if (from === end) {
        eventEmitter.fire('clear');
        return;
    }
    if (from > end) {
        var t = from;
        from = end;
        end = t;
    }
    var text = target.value.substring(from, end);
    if (text) {
        eventEmitter.fire('select', {
            from: from,
            end: end,
            text: text
        });
    } else {
        eventEmitter.fire('clear');
    }
});