背景
前端项目开发中,为用户输入内容提供一个编辑框是非常常见的基础交互需求。 比如博客文章下面的评论区、可视化富文本编辑、在线代码编辑器...
根据不同的业务场景,我们应该做怎么样的调研?怎么决定选型?相关技术点又有哪些?
基础效果
比如某个历史项目里,有下图这么一个编辑器。
通过示意可以看到,大致功能:
- 可以输入文本。
- 编辑器外点击表情图,插入类似“[微笑]”的方括号定界描述符。
- 编辑器外点击人名,可以插入“@xxx ”。
历史问题
通过读源码,发现这个项目编辑器的选型是基于开源的Slate
、以及依赖的一些模块 slate-react
、slate-plug-xxx
等实现的。
继续读代码发现,原来除了需要 insertText
到目标光标位置,并且输入“@xx”后,还需要按照光标在文档流中坐标位置,展示备选菜单。
基于Slate
的API和插件,确实可以很方便的实现插入内容到光标位置、光标坐标获取,但也很明显带来了新的问题:
- 三方依赖包体量过大。
- 对中文输入法兼容不友好:会意外多出现类似“zhong'wen'xxx”的内容。
- 编辑器自带个别能力与上下文环境冲突,会导致JS崩溃、React渲染空白等。
- ...
参考前文功能需求描述,这选型无疑是高射炮打蚊子了。 而且带来的问题确实不能容忍。
精简提效
相对Slate
来说更换成原生HTML元素 <textarea>
,是更轻量级、更稳妥可靠的一个方案。这里就不具体赘述了,一个简化的实现效果可到 https://lab.pyzy.net/txt_editor.html 查看。
隐含功能
除了直观上看到的纯文本编辑,额外还有看不到的能力:
- 被@人员列表数据。
- 草稿存储。
- 消息撤回重新编辑。
除了(1),只能用额外数据列表记录,2和3基于纯文本都比较好实现,2直接存储String到localStorage,3直接insertText(String)全文就可以了。
新需求-“图文混发”
"需求文档":
最终实现效果
相对前面的纯文本编辑,区别也很明显,这里@、表情是所见即所得了,同时另外支持了插入图片(图文混排)。
最终达成功能概要
通过前面截图,我们视觉能直观看到编辑时达成的功能,基本可归纳为:
- 可以输入任意纯文本内容。
- 通过表情面板,点击插入所见即所得的图片表情图。
- 通过@面板,可以插入高亮的“@xxx ”HTML标记。
- 通过工具栏文件选择或粘贴截图来插入图片。
而点击发送时,我们再将编辑器呈现的富文本,转换为符合发送需要的JSON描述符即可。比如:
- 表情图转为"[微笑]"描述符;
- 高亮的“@xxx”转为纯本文,并提取被@人uid、uname,生成JSON数据;
- 将插入的图片上传服务端,得到文件唯一标识,放入JSON描述体中,并对应到文本中应该在的位置。
- ...
“图文混发”关键技术环节
- 技术选型
- insertText 改造。
- 富文本与正文数据互转。
- 光标位置。
- 草稿存储改造(含插入图片存储)。
- 兼容性。
技术选型
基于前文考虑,如果我们使用现成的开源编辑器,自然开发成本最低,但稳定性和维护成本无法估量。
这里,最终选择了基于原生的 <div contenteditable="true" />
为业务量身打造一个编辑器。
insert 方法改造
在基于<textarea>
实现编辑时,insertText
的实现我们基于 textareaHTMLElement.value
、textareaHTMLElement.selectionStart
、textareaHTMLElement.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;
或其他相同作用的设置。
后记
相关细节点还有很多,暂不逐一赘述了,以上先仅做流水记录,如果有所帮助、或拓展问题,欢迎沟通。
Comments
可以发邮件 huzunjie@pyzy.net 或移步到 https://github.com/huzunjie/blog.pyzy.net/issues 评论交流。