contenteditable 简单富文本输入框的实现与踩坑记录

contenteditable 简单富文本输入框的实现与踩坑记录

(鉴于本站好久没有发技术文了,所以正经一下)HTML5 的contenteditable 属性可以用来实现一个简单的富文本输入框。在项目的某个需求中有用到这个特征,以下是相关实现记录及踩坑备忘。

需求实现要求如下:

左侧为输入框,右侧为预览框。

左侧如果是 textarea,那么与之前类似的需求(欢迎语、群发消息)并无区别。然而,“插入昵称”功能的引入,使得无法直接用 textarea——“插入昵称”要求能在任意地方的光标处插入,需要支持按块删除,需要支持复制粘贴不丢失“昵称”状态等等。

于此,左侧的 textarea改为 Div+contenteditable 的结构。需求于是变成了在满足上面的特殊点的前提下,尽可能让Div+contenteditable 无限对齐textarea 的行为表现。嗯,还有考虑浏览器兼容性。

实现

这里的实现仅针对关键实现点进行说明。

“插入昵称”功能

左侧原先 textarea 修改为如下结构:

<div contenteditable="true" class="" maxlength="3000" id="" placeholder="如:你好,欢迎加入..."></div>
<input type="button" value="插入客户昵称" class="">

为 input “插入框”绑定点击事件,执行一个“在contenteditable div 的光标处插入相关 DOM”的功能。这个功能需要借助Selection API来实现。

这里其实有很多坑,比如你点击“插入框”的时候,光标其实是已经移动到“插入框”上面了,而不是输入框的光标。这里直接后人乘凉,给出关键有效的函数。

function insertHtmlAtCaretNew(html) {
    var sel, range;
    if (window.getSelection) {
        // IE9 and non-IE
        sel = window.getSelection();
        if (sel.getRangeAt && sel.rangeCount) {
            range = sel.getRangeAt(0);
            range.deleteContents();
            // Range.createContextualFragment() would be useful here but is
            // non-standard and not supported in all browsers (IE9, for one)
            var el = document.createElement('div');
            el.innerHTML = html;
            var frag = document.createDocumentFragment(),
                node, lastNode;
            while ((node = el.firstChild)) {
                lastNode = frag.appendChild(node);
            }
            range.insertNode(frag);
            // Preserve the selection
            if (lastNode) {
                range = range.cloneRange();
                range.setStartAfter(lastNode);
                range.collapse(true);
                sel.removeAllRanges();
                sel.addRange(range);
            }
        }
    } else if (document.selection && document.selection.type !== 'Control') {
        // IE < 9
        document.selection.createRange().pasteHTML(html);
    }
}

点击“插入框”后执行:

textareaDom.focus();
insertHtmlAtCaretNew('<img src="name.svg" alt="#客户昵称#">');

在输入框中,“客户昵称”是一个img标签的结构,不用span+样式的实现有如下原因:

1)span+样式会有很多潜在的问题:比如插入光标有可能触达到样式,新增文字就可能一直落在 span 内部,这种情况下处理起来巨麻烦;

2)img标签的结构让整块删除变得容易且兼容性好;

3)img标签里面有一个alt属性,里面就是我定义的关键词“#客户昵称#”,复制带这个块的时候的文段,浏览器自动解析为纯文本,恰到好处的实现!

改写粘贴事件

contenteditable 的输入框粘贴事件需要承载至少两个功能:一是解析带“#客户昵称#”关键词的文段;二是粘贴的时候过滤带样式内容。因此需要改写默认的粘贴事件。

页面初始化完成后绑定自定义的粘贴事件。

$(document).on('paste', '#js_csMessage_create_ta', onPaste);

重写的粘贴事件如下:

 
function onPaste(e) {
e.preventDefault();
var text = '';
 
if (window.clipboardData && window.clipboardData.getData) {
    // IE
    text = window.clipboardData.getData('Text');
    text = T.htmlEscape(text);
    // ie 中直接粘贴纯文本
    if (window.getSelection) window.getSelection().getRangeAt(0).insertNode(document.createTextNode(text));
    return false;
} else if (e.originalEvent.clipboardData) {
    // text = e.clipboardData.getData('text/plain');
    text = (e.originalEvent || e).clipboardData.getData('text/plain');
    text = T.htmlEscape(text);
}
// 换行等处理
text = text.replace(/\r\n/gi, '<br/>').replace(/\n/gi, '<br/>');
// 包含关键词的就得转化下
if (text.indexOf(NICKNAME_KEYWORD) > -1) {
    var array = text.split(NICKNAME_KEYWORD);
    document.execCommand('insertHTML', false, array.join('<img src="' + NICKNAME_IMG_SRC + '" alt="' + NICKNAME_KEYWORD + '">'));
} else {
    document.execCommand('insertHTML', false, text);
}
}

输入框内容的解析与保存

输入框里面本质是 HTML 代码,右侧的预览图包括最终存储到后台的数据都是纯文本,就涉及到“HTML代码转纯文本数据”的实现。因为“昵称”的引入,这里固然不是简单的$(id).text()的实现。

我是用html-parse-stringify这个库将 HTML 解析为 AST 然后取我需要的内容。理论上编辑器里面的内容对我有效的只有三种类型:文本、img标签、换行br标签。后续的预览展示与保存,跟后台数据存储相关,这里就不展开了。

踩坑记录

支持 placeholder

模拟支持 textarea 的placeholder 特征:

[contentEditable=true]:empty:not(:focus):before {
  color: #eeee;
  content: attr(placeholder)
}

注意:在写HTML 时候,contenteditable Div 一定要闭合,不要留空或换行。

<div contenteditable="true"></div>

处理Chrome 下contenteditable div 自动产生多余结构

Chrome 下的编辑器编辑器在进行一些换行等操作会自动在contenteditable div 生成多余的 div、p、span等标签。

网络上的解决方式其实很简单,把块弄为 inline-block

处理 IE 系contenteditable div 下换行自动产生的多余结构

与上面的类似,IE/Edge 中换行也会产生多余的 div 结构,解决方法是通过$(textarea).html()获取到的 html 结构稍微二次处理下:

if (document.documentMode || /Edge/.test(navigator.userAgent)) {
    valueHtml = valueHtml.replace(/<\/div>/gim, '<br/>');
    valueHtml = valueHtml.replace(/<div>/gim, '');
}

&nbsp; 解析的问题

当在 div 的最末尾添加一个空格,预览时候会展示成HTML 格式的&nbsp;,即这种时候通过$(textareaid).text()获取到的 value,空格会自动转义为&nbsp;

查了下资料为 chrome 的 bug,这里修复下:

value = value.replace(/&amp;nbsp;/gi, ' ');

换行的解析

换行这里在保存及读取,复制粘贴时候均有遇到,合适的时机 replace 下就好。

text = text.replace(/\r\n/gi, '<br/>').replace(/\n/gi, '<br/>');

Safari 中光标过大的问题

Safari存在初次点击输入框光标占据整个块,重新点时候正常。一图描述这种现象:

解决方式:前端 DMO 结构弄成两个,

一个为外层不可编辑,一个为 contentable ,实质就是为 textarea 的作用,然后外层限高,里层自增(height:auto);

Paste 事件多次触发

两种处理,双管齐下:

1) 页面销毁时候取消监听 $(document).off('paste');

2) 节流:

$(document).on('paste', '#js_csMessage_create_ta', _.throttle(function (e) {
    onPaste(e);
}, 100));

IE中图片元素resize

在 IE 中,contenteditable div 下的图片能被鼠标放大缩小,用如下样式进行处理:

 img {
    resize: none;
    pointer-events: none;
}

更多坑点陆续更新中...

本文头图来自pixabay

评分:
当前平均分 4.67 (92%) - 3 个投票
5 条 评论
  1. 正有打算做一个类似的,感谢分享!

    3年前 回复
  2. 博主确实好久没更新了 :lol:

    3年前 回复
  3. 感谢分享,学习一下

    3年前 回复
  4. jerry

    想说一下,学习一下

    4年前 回复
  5. 文章不错非常喜欢

    4年前 回复
发表评论