05月13, 2021

记一个网页端IM文本编辑器的演进过程

背景

前端项目开发中,为用户输入内容提供一个编辑框是非常常见的基础交互需求。 比如博客文章下面的评论区、可视化富文本编辑、在线代码编辑器...

根据不同的业务场景,我们应该做怎么样的调研?怎么决定选型?相关技术点又有哪些?

基础效果

比如某个历史项目里,有下图这么一个编辑器。

alt

通过示意可以看到,大致功能:

  1. 可以输入文本。
  2. 编辑器外点击表情图,插入类似“[微笑]”的方括号定界描述符。
  3. 编辑器外点击人名,可以插入“@xxx ”。

历史问题

通过读源码,发现这个项目编辑器的选型是基于开源的Slate、以及依赖的一些模块 slate-reactslate-plug-xxx等实现的。

继续读代码发现,原来除了需要 insertText 到目标光标位置,并且输入“@xx”后,还需要按照光标在文档流中坐标位置,展示备选菜单。

alt

基于Slate的API和插件,确实可以很方便的实现插入内容到光标位置、光标坐标获取,但也很明显带来了新的问题:

  1. 三方依赖包体量过大。
  2. 对中文输入法兼容不友好:会意外多出现类似“zhong'wen'xxx”的内容。
  3. 编辑器自带个别能力与上下文环境冲突,会导致JS崩溃、React渲染空白等。
  4. ...

参考前文功能需求描述,这选型无疑是高射炮打蚊子了。 而且带来的问题确实不能容忍。

精简提效

相对Slate来说更换成原生HTML元素 <textarea>,是更轻量级、更稳妥可靠的一个方案。这里就不具体赘述了,一个简化的实现效果可到 https://lab.pyzy.net/txt_editor.html 查看。

隐含功能

除了直观上看到的纯文本编辑,额外还有看不到的能力:

  1. 被@人员列表数据。
  2. 草稿存储。
  3. 消息撤回重新编辑。

除了(1),只能用额外数据列表记录,2和3基于纯文本都比较好实现,2直接存储String到localStorage,3直接insertText(String)全文就可以了。

新需求-“图文混发”

"需求文档":

alt

最终实现效果

alt

相对前面的纯文本编辑,区别也很明显,这里@、表情是所见即所得了,同时另外支持了插入图片(图文混排)。

最终达成功能概要

通过前面截图,我们视觉能直观看到编辑时达成的功能,基本可归纳为:

  1. 可以输入任意纯文本内容。
  2. 通过表情面板,点击插入所见即所得的图片表情图。
  3. 通过@面板,可以插入高亮的“@xxx ”HTML标记。
  4. 通过工具栏文件选择或粘贴截图来插入图片。

而点击发送时,我们再将编辑器呈现的富文本,转换为符合发送需要的JSON描述符即可。比如:

  1. 表情图转为"[微笑]"描述符;
  2. 高亮的“@xxx”转为纯本文,并提取被@人uid、uname,生成JSON数据;
  3. 将插入的图片上传服务端,得到文件唯一标识,放入JSON描述体中,并对应到文本中应该在的位置。
  4. ...

“图文混发”关键技术环节

  1. 技术选型
  2. insertText 改造。
  3. 富文本与正文数据互转。
  4. 光标位置。
  5. 草稿存储改造(含插入图片存储)。
  6. 兼容性。

技术选型

基于前文考虑,如果我们使用现成的开源编辑器,自然开发成本最低,但稳定性和维护成本无法估量。

这里,最终选择了基于原生的 <div contenteditable="true" /> 为业务量身打造一个编辑器。

insert 方法改造

在基于<textarea>实现编辑时,insertText的实现我们基于 textareaHTMLElement.valuetextareaHTMLElement.selectionStarttextareaHTMLElement.selectionEnd便可以得到输入的完整文本内容、选中区间,然后进行替换、重写,基于textareaHTMLElement.setSelectionRange(sta, end)重新设置选中内容区间。(详见示例源码 )

基于<div contenteditable="true" />时,insertText的实现,需要转换为 insertNodes,对应到基础层各Nodes类型的insert方法代码片段如下:

// 注意:下面代码仅是示意,包含缺少定义的环境变量

// 创建一个文本节点
function createTextNode(text) {
  return document.createTextNode(String(text));
}
function createBrEl() {
  return document.createElement('br');
}
// 创建一个 At El
function createAtEl(uid, name) {
  const atEl = document.createElement('span');
  atEl.className = atCls;
  atEl.innerText = '@' + name + _atSuffix;
  setAttributes(atEl, {
    'data-uid': uid,
    'data-name': name,
    contenteditable: false, // 不可编辑修改Span内容
  });
  return atEl;
}

// 创建一个表情图 El
function createEmojiEl(tips, src) {
  const emojiEl = document.createElement('img');
  emojiEl.className = emojiCls;
  setAttributes(emojiEl, {
    src,
    alt: `[${tips}]`,
    'data-tips': tips,
  });
  return emojiEl;
}
// 创建一个插图 El
function createImgEl(file, width, height) {
  let src = '';
  let title = '';
  let draftFid = '';
  if (file) {
    draftFid = getDraftFidByFile(file);
    title = file.name;
    try {
      src = URL.createObjectURL(file);
    } catch (err) {}
  }
  const imgEl = document.createElement('img');
  imgEl.className = picCls;
  setAttributes(imgEl, {
    src,
    title,
    alt: '[图片]',
    'data-width': width,
    'data-height': height,
    'data-draft-fid': draftFid,
  });
  /*
  这里的图片产品需求中要支持双击打开图片查看器预览,
  但用户是否预览、是否多图、什么时机预览不确定的,
  所以不可以 revokeObjectURL 掉。
  if (src) {
    imgEl.onload = () => {
      try {
        URL.revokeObjectURL(src);
      } catch (err) {}
    };
  }*/
  return imgEl;
}

富文本与正文数据互转

首先,看信息字串转为富文本节点数组。

归根核心,也就是怎么将类似"测试文本 [微笑] @xxx 等等等"的消息字串,转为能使用insertNodes插入富文本编辑器的节点数组。

具体实现,依赖一个名为 rich2nodeRegExp 的正则变量,将文本中内容按定界符提取并替换为对应HTMLNode。

// 注意:下面代码仅是示意,包含缺少定义的环境变量
function richText2Nodes(richText) {
    const rtelNodes = [];
    const rtelTexts = String(richText)
      .replace(rich2nodeRegExp, (str, br, atName, emojiTips) => {
        if (br) {
          rtelNodes.push(createBrEl());
          return rtelSplitter;
        } else if (atName) {
          const atUser = at && _.find(at, ({ name }) => name === atName);
          if (atUser) {
            const { uid, name } = atUser;
            rtelNodes.push(createAtEl(uid, name));
            return rtelSplitter;
          }
        } else if (emojiTips) {
          const emojiItem = emojiMaps[emojiTips];
          if (emojiItem) {
            rtelNodes.push(createEmojiEl(emojiTips, emojiItem.url));
            return rtelSplitter;
          }
        }
        return str;
      })
      .split(rtelSplitter);
    _.forEach(rtelTexts, (txt, i) => {
      rtelNodes.push(createTextNode(txt));
    });
    return rtelNodes;
}

接下来,再看怎么将富文本编辑器内,用户输入的内容转为消息JSON数据。

// 注意:下面代码仅是示意,包含缺少定义的环境变量

function richTextNodes2MsgData(richTextEl) {
  const ret = {
    txt: '', // 用于纯文本展示需求
    at: [], // 用于记录at人员名单 [{ uid, name }]
    msgs: [], // 用于支持图文混排 [{ key: 'txt', val: '' }, { key: 'img', width: 123, height: 456, file: File }]
  };
  if (!_.get(richTextEl, 'childNodes.length')) {
    // 输入框是空的,直接返回空消息体内容
    return ret;
  }
  /* 图、文(含At文本)、表情提取 sta */
  const picEls = richTextEl.getElementsByClassName(picCls);
  // 生成图文混排中的图片定界符
  const picSplitter = `[IMG:${getUuid()}]`;
  const tmpEl = getTransCodeEl();
  // 用定界符替换用户插入图片产生的DOM元素
  tmpEl.innerHTML = richTextEl.innerHTML
    // 去除用来便于光标移动的0宽字符
    .replace(blankRegExp, '')
    // 插图转为定界符
    .replace(picTagRegExp, picSplitter)
    // 表情图转未文本标记符如“[微笑]”
    .replace(emojiTagRegExp, (str) => (emojiTipsRegExp.exec(str) || '')[1]);

  const { msgs } = ret;
  // 格式化消息内容
  const txts = tmpEl.innerText.split(picSplitter);
  const maxI = txts.length - 1;
  _.forEach(txts, (val, i) => {
    const picEl = picEls[i];
    if (val) {
      if (i === 0) {
        // 对第一条文本消息的前面换行符进行过滤; 不可以做trim处理
        val = val.replace(/^[\r\n]+/, '');
      }
      if (i === maxI && !picEl) {
        // 对最后一条文本消息的末尾换行符进行过滤; 不可以做trim处理
        val = val.replace(/[\r\n]+$/, '');
      }
      if (val) {
        ret.txt += val;
        msgs.push({ key: 'txt', val });
      }
    }
    if (picEl) {
      ret.txt += '[图片]';
      const {
        'data-width': width,
        'data-height': height,
        'data-draft-fid': draftFid,
      } = getAttributes(picEl, ['data-width', 'data-height', 'data-draft-fid']);
      msgs.push({
        key: 'img',
        height,
        width,
        file: getFileByDraftfId(draftFid),
      });
    }
  });
  /* 图、文(含At文本)、表情提取 end */

  /* At列表提取 sta */
  const { at } = ret;
  const atEls = richTextEl.getElementsByClassName(atCls);
  _.forEach(atEls, (el) => {
    const { 'data-uid': uid, 'data-name': name } = getAttributes(el, [
      'data-uid',
      'data-name',
    ]);
    at.push({ uid, name });
  });
  /* At列表提取 end */
  tmpEl.innerHTML = '';
  return ret;
}
`

富文本内容转消息JSON,后便可以进行草稿存储、提交服务端了,大体格式如下:

 {
    txt: '', // 用于纯文本展示需求
    at: [], // 用于记录at人员名单 [{ uid, name }]
    msgs: [], // 用于支持图文混排 [{ key: 'txt', val: '' }, { key: 'img', width: 123, height: 456, file: File }]
  }

光标位置读写

这里按基础能力需要,也需要拆分多个核心基础方法。

首先,需要最基本的光标选取读写能力。

function getSel() {
  return (window.getSelection && window.getSelection()) || {};
}

// 更换选中内容
function replaceRange(opts) {
  try {
    const { staNode, staOffset, endNode, endOffset } = opts;
    if (!staNode.parentNode || !endNode.parentNode) {
      return;
    }
    const ra = document.createRange();
    ra.setStart(staNode, amendChildOffset(staNode, staOffset));
    ra.setEnd(endNode, amendChildOffset(endNode, endOffset));
    const sel = getSel();
    sel.removeAllRanges();
    sel.addRange(ra);
  } catch (err) {
    console.log('[Errror] replaceRange', opts, err);
  }
}
// 修正子元素定位下标值
function amendChildOffset(pEl, childOffset) {
  return Math.min(
    childOffset,
    _.get(pEl, 'childNodes.length') || _.get(pEl, 'length') || 0
  );
}
// 移动光标到目标元素的第几个子内容后面
function moveOffset(targetNode, idx = 0) {
  if (!targetNode) return;
  replaceRange({
    staNode: targetNode,
    staOffset: idx,
    endNode: targetNode,
    endOffset: idx,
  });
}
// 移动光标到目标节点前后 @isAfter 是否定位到目标后面
function moveOffset2Node(nodeEl, isAfter = false) {
  try {
    const parentNd = nodeEl.parentNode;
    if (parentNd) {
      const idx = _.indexOf(parentNd.childNodes, nodeEl) + (isAfter ? 1 : 0);
      moveOffset(parentNd, idx);
    }
  } catch (err) {
    console.log('[Errror] moveOffset2Node:', nodeEl, isAfter, err);
  }
}

按光标所在位置获取左侧文本内容及文本节点对象。

getCursorLeftObj = () => {
  this.focus(); // 先保证光标在输入框里
  const sel = getSel();
  const ret = { sel };
  const { anchorNode, focusNode, anchorOffset, focusOffset } = sel;
  // 光标不在一个独立的文本节点内,或者用户有在主动选取文本内容,则不做At文案提取
  if (
    focusNode &&
    anchorNode === focusNode &&
    isTxtNode(focusNode) &&
    !isNaN(focusOffset) &&
    anchorOffset === focusOffset
  ) {
    const { data } = focusNode;
    if (data) {
      ret.node = focusNode;
      ret.offset = focusOffset;
      // 光标左侧文本内容
      ret.txt = data.substring(0, focusOffset);
    }
  }
  return ret;
};

获取光标在文档流中的坐标位置。

getRangePosition = () => {
  this.focus(); // 先保证光标在输入框里
  const sel = getSel();
  let ret = {};
  if (sel.getRangeAt) {
    const ra = sel.getRangeAt(0);
    if (ra && ra.getBoundingClientRect) {
      ret = ra.getBoundingClientRect();
    }
  }
  return ret;
};

编辑器内容插入的入口方法

结合上文的各种方法,还需要再实现一个可以一次批量插入多个节点到编辑器的 insertNodes 方法,作为内容插入的总入口:


  // 插入多个HTML节点元素到光标区域
  insertNodes = (nodes) => {
    // this.focus(); // 让编辑器输入区为焦点获取状态
    try {
      const sel = getSel();
      const richEl = document.getElementById('richTextEl');
      const fnode = sel.focusNode;
      if (!fnode || (fnode !== richEl && !richEl.contains(fnode))) {
        return; // 防止内容插入到编辑器区域外
      }
      let ra = null;
      if (sel.rangeCount) {
        ra = sel.getRangeAt(0);
        ra.deleteContents();
      } else {
        ra = document.createRange();
        sel.removeAllRanges();
        sel.addRange(ra);
      }
      const nodeCount = _.get(nodes, 'length');
      if (nodeCount) {
        const fragment = document.createDocumentFragment();
        const atEls = [];
        _.forEach(nodes, (el, i) => {
          isAtNode(el) && atEls.push(el);
          fragment.appendChild(el);
        });
        ra.insertNode(fragment); // 关键就是这里

        // autoAddBlank(atEls);  // 为Element补全光标占位符
        // 光标到最后一个元素后
        moveOffset2Node(nodes[nodeCount - 1], true);
      }
    } catch (err) {
      console.log('insertNode Err:可能是浏览器环境兼容问题。', err);
    }
    // 触发变更事件
    // this.onChangeHandler();
  };

草稿存储改造(含插入图片存储)

原有草稿存储,是基于本地存储的 localStorage.set localStorage.get;如果要进行图片文件存储,肯定是不合适的,所以这里又引入了 indexedDB,写了一个 ttIndexedDb 操作对象。

const ttIndexedDb = ((win) => {
  let { _ttIndexedDb } = win;
  if (_ttIndexedDb) return _ttIndexedDb;

  win._hzj_idx_db_log = 0;

  idxDbPolyfill(win);

  // 打开并连接的 database 对象
  let db = null;

  // 草稿文件存储对象名称
  const draftFilesStoreName = 'draft_files';
  _ttIndexedDb = win._ttIndexedDb = {
    /* 读草稿文件从本地数据存储
     * @sid 会话ID
     * 返回 Promise
     */
    async readDraftFiles(sid) {
      return await this.readwriteStore({
        storeName: draftFilesStoreName,
        action: 'get',
        data: sid,
      });
    },
    /* 写草稿文件到本地数据存储
     * @data 草稿数据,示例: {
        sid: '123', 
        files: {
         'b456': new File(['123333'], 'test33.txt'),
         'b4563': new File(['123333'], 'test33.txt'),
        }
      }
     * 返回 Promise
     */
    async writeDraftFiles(data) {
      return await this.readwriteStore({
        storeName: draftFilesStoreName,
        action: 'put',
        data,
      });
      // if (req.error) console.log('写草稿文件失败!');
    },
    /* 删除草稿文件从本地数据存储
     * @sid 会话ID
     * 返回 Promise
     */
    async delDraftFiles(sid) {
      return await this.readwriteStore({
        storeName: draftFilesStoreName,
        action: 'delete',
        data: sid,
      });
    },
    /* 按名称、行为、参数,读写存储对象中对应数据
     * @storeName 存储对象名称
     * @action    操作行为,可选值:get、put、...
     * @data    行为对应的参数们
     * 返回 Promise
     */
    readwriteStore({ storeName, action = 'get', data }) {
      return new Promise(async (resolve) => {
        const isRead = action === 'get';
        const { db, error } = await this.openDb();
        if (error) {
          return resolve({ error });
        }
        try {
          const store = db
            .transaction(storeName, isRead ? 'readonly' : 'readwrite')
            .objectStore(storeName);
          const req = store[action](data);
          req.onsuccess = () => resolve(req);
          req.onerror = (error) => resolve({ error });
        } catch (error) {
          win._hzj_idx_db_log &&
            console.log('[Error] indexedDB.readwriteStore', error);
          resolve({ error });
        }
      });
    },
    // 打开数据库
    openDb() {
      return new Promise((resolve) => {
        if (db) {
          resolve({ db });
          return;
        }
        const { indexedDB } = win;
        if (!indexedDB) {
          win._hzj_idx_db_log &&
            console.log('[OpenError] Does not support with indexedDB!');
          resolve({ error: '[OpenError] Does not support with indexedDB!' });
        }
        const req = indexedDB.open('TX_IM', 1);
        req.onupgradeneeded = (e) => {
          win._hzj_idx_db_log &&
            console.log('[onupgradeneeded] indexedDB.open', e);
          // 创建草稿文件存储对象
          db = e.target.result;
          const store = db.createObjectStore(
            draftFilesStoreName, // 草稿文件存储对象
            // 按会话ID作为主键
            { keyPath: 'sid', autoIncrement: false }
          );
          // 支持按会话ID去索引
          store.createIndex('sid', 'sid', { unique: true });
        };
        req.onerror = (e) => {
          db = null;
          win._hzj_idx_db_log && console.log('[Error] indexedDB.open', e);
          resolve({ error: '[OpenError]' + req.error });
        };
        req.onsuccess = (e) => {
          win._hzj_idx_db_log && console.log('[onsuccess] indexedDB.open', e);
          db = e.target.result;
          db.onerror = (e) => {
            win._hzj_idx_db_log &&
              console.log('[onerror] creating/accessing IndexedDB database', e);
          };
          db.onclose = (e) => {
            win._hzj_idx_db_log && console.log('[onclose] indexedDB.open', e);
            db = null;
          };
          // 数据库版本变化,则关闭之(下次调用再开启)
          db.onversionchange = (e) => {
            win._hzj_idx_db_log &&
              console.log('[onversionchange] indexedDB.open', e);
            db.close();
          };
          resolve({ db });
        };
      });
    },
  };
  return _ttIndexedDb;
})(window);

// API兼容抹平问题
function idxDbPolyfill(win) {
  if (!win.indexedDB) {
    win.indexedDB =
      win.webkitIndexedDB ||
      win.mozIndexedDB ||
      win.OIndexedDB ||
      win.msIndexedDB;
  }
  if (
    win.indexedDB &&
    IDBObjectStore &&
    IDBObjectStore.prototype &&
    typeof IDBObjectStore.prototype.getAll !== 'function'
  ) {
    IDBObjectStore.prototype.getAll = (...args) => {
      const ret = {};
      const req = this.openCursor(...args);
      req.onerror = (...errArgs) => {
        const { onerror } = ret;
        if (typeof onerror == 'function') {
          onerror(...errArgs);
        }
      };
      const retArr = [];
      req.onsuccess = (...sucArgs) => {
        const { onsuccess } = ret;
        if (typeof onsuccess == 'function') {
          const { target } = sucArgs[0] || {};
          const cursor = target.result;
          if (cursor) {
            retArr.push(cursor.value);
            cursor.continue();
          } else {
            ret.result = retArr;
            target.result = retArr;
            onsuccess(...sucArgs);
          }
        }
      };
      return ret;
    };
  }
}

export default ttIndexedDb;

兼容性

细节问题很多,先记录两个。

在Safari浏览器端,contenteditable的元素可能还是无法获取光标的不可编辑状态,一定记得添加CSS:

[contenteditable]{
    -webkit-user-select: text;
    user-select: text;
}

兼容性方面还要特别注意,focus到DIV并不能保证保持之前的光标选中状态,所以这里要自己做光标状态记录、和恢复。

onSelectionChange = () => {
    // 进一步保证focus方法无效的问题
    const { richTextEl } = this.refs;
    const {
      focusNode: staNode,
      focusOffset: staOffset,
      anchorNode: endNode,
      anchorOffset: endOffset,
    } = getSel();
    if (
      (endNode === richTextEl || richTextEl.contains(endNode)) &&
      (staNode === richTextEl || richTextEl.contains(staNode))
    ) {
      this._selCache = { staNode, staOffset, endNode, endOffset };
      // console.log('记录光标状态!', this._selCache)
    }
  };
  // 设置输入框为焦点元素
  focus = () => {
    if (this.state.draftReading) return;
    const { richTextEl } = this.refs;
    // 恢复光标选中状态
    const { focusNode } = getSel();
    if (
      !focusNode ||
      (focusNode !== richTextEl && !richTextEl.contains(focusNode))
    ) {
      const staOffset = richTextEl.childNodes.length;
      const rangeOpts = this._selCache || {
        staNode: richTextEl,
        staOffset,
        endNode: richTextEl,
        endOffset: staOffset,
      };
      // console.log('恢复光标状态', rangeOpts)
      replaceRange(rangeOpts);
    }
    richTextEl.focus();
  };

特殊内容相关

上文中反复有用到一个 getTransCodeEl()方法,该方法完整代码如下:

// 用临时容器来解析富文本消息内容为JSON数据体
function getTransCodeEl() {
  const tmpId = 'rich_text_trans';
  let tmpEl = document.getElementById(tmpId);
  if (!tmpEl) {
    tmpEl = document.createElement('div');
    tmpEl.id = tmpId;
    tmpEl.setAttribute('desc', '富文本与消息数据互转专用');
    // 必须添加到文档流才能保证正常得到换行符
    document.body.appendChild(tmpEl);
  }
  return tmpEl;
}

对应CSS如下:

#rich_text_trans {
    position: absolute;
    top: -9px;
    left: -9px;
    overflow: hidden;
    width: 1px;
    height: 1px;
    white-space: pre; // 保证空格、制表符正常渲染展示并能通过innerText 获取
}

所以不要忘了,基于 contentEditable 实现的编辑输入框,也要按自己需求添加 white-space: pre-wrap;或其他相同作用的设置。

后记

相关细节点还有很多,暂不逐一赘述了,以上先仅做流水记录,如果有所帮助、或拓展问题,欢迎沟通。

本文链接:http://blog.pyzy.net/post/editor.html

-- EOF --

Comments

可以发邮件 huzunjie@pyzy.net 或移步到 https://github.com/huzunjie/blog.pyzy.net/issues 评论交流。