我正在参与掘金创作者训练营第4期,点击了解活动详情,一起学习吧!
之前做了一个看似简单,实际却有很多坑的输入联想功能,与 @
选人功能类似,今天把踩坑记录分享给大家。
需求描述
-
文本输入框;
-
输入
@
符号后/@
+联想关键字,联想出结果列表,在光标处浮窗显示; -
点击联想结果列表,将已键入的
@
+联想关键字替换为点选联想结果,并蓝色字体显示; -
触发联想后,未点选联想结果,则仅作为纯文本,未点选的通过空格结束联想;
-
未提交前,可以反复编辑,所以需要已编辑内容的保存和回填显示,数据库存储
html
字符串,方便反复编辑回填显示; -
编辑后失去焦点即保存编辑内容;完成并提交后不可再编辑,仅可以查看,查看时对于高亮文字鼠标悬浮时候浮窗显示更多信息;
-
显示
Placeholder
; -
长度限制:最大
200
个字符(文本框右下角显示已输入字符统计/总字符); -
所有类型的格式均可接受,包括数字、中英文、特殊符号、换行、空格等;
功能分析
由于调研过程中没有找到富文本编辑器的限制最大字数功能,所以舍弃;
于是想到了contentEditable Div,其宽高支持自定义,支持内部节点带css
样式,看起来基本是满足需求的;
其他Placeholder
、实时长度、最大长度限制、输入监听看起来都属于常规功能(这里评估阶段严重低估难度和复杂度的重要原因,自己估的工作量,自己加班也要完成啊。。。)
功能开发
项目是React + AntD
开发的。
结构
<div className={'editDivContentWrap'}>
<div
contentEditable={true}
ref={editableDivRef}
placeholder={placeholder}
dangerouslySetInnerHTML={{ __html: htmlStr }}
onKeyUp = {handleEditableKeyUp}
onKeyPress = {handleEditableKeyPress}
onKeyDown = {handleEditableKeyDown}
onBlur = {handleEditBlur}
onMouseUp={handleEditableMouseUp}
onPaste={handleEditablePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
<div className={'textLength'}>
<span>{hasInputLength}</span>/
<span>{MAXLENGTH}</span>
</div>
{
showSearchResult &&
<div className={'searchResultTableWrap'}>
XXX
</div>
}
</div>
复制代码
踩坑记录
Placeholder
在实现看似最简单的 Placeholder
时候就遇到了问题,哭泣。。。
contentEditable
div
回车
键入 <div><br></div>
再输入英文 1
会替换为 <div>1</div>
;
输入框内没有内容时候,第一次键入回车会键入 <div><br></div><div><br></div>
,因为如果不键入两个换行会在视图上表现为没有换行,这是浏览器的处理;
尝试使用 \n
实现换行,结果出现下面的问题,所以还是选择了浏览器的处理;
Enter
阻止默认键入,替换为\n
输入框内没有内容时候,第一次键入Enter
会在页面上表现为没有换行,且中文输入法下,第一次输入回车后再第一次键入会键入两个第一个字母;
myPlaceholder
最终 Placeholder
实现;
const handlePlaceholder = () => {
// 在编辑框里直接回车,然后再删除,就不会再出现placeholder问题处理
const htm = editableDivRef.current.innerHTML;
if (htm.length !== 0 && htm !== '<div><br></div>') {
setMyPlaceholder('');
} else {
setMyPlaceholder('myPlaceholder');
if (htm === '<div><br></div>') {
editableDivRef.current.innerHTML = '';
}
}
}
复制代码
- 联想浮窗
然后是联想浮窗,依然是问题重重,哭唧唧。。。
这里没有实现按键控制选择联想结果,放在下一期了,实在肝不过来了,感谢产品大大不杀之恩;
- 点选联想结果替换成高亮文本
选取@
+联想关键字,替换为高亮文本;
高亮文本使用Button+Css
实现,高亮文本作为一个整体处理,只能整体删除,不可选择和编辑;
const itemClick = (record) => {
// 隐藏浮窗
updateSearchFlag(false, null);
// 获取已输入内容,用于最大长度判断
const txt = editableDivRef.current.textContent;
const length = txt.length;
// 点选联想结果中需要使用的文本,用于高亮展示
const code = record.codeNum || '';
const addCodeLen = code.toString().length;
if (length > MAXLENGTH || (addCodeLen + length > MAXLENGTH)) {
// 超出最大长度不能继续输入
maxLengthTipFun();
return false;
} else {
// 更新实时长度
setHasInputLength(addCodeLen + 1 + length);
}
if (editableDivRef.current) {
editableDivRef.current.focus();
// 删掉联想草稿start
const editorRangeRange = editorRange.range;
if (!editorRangeRange) return;
// 拿到末尾文本节点
const textNode = editorRangeRange.endContainer;
// 光标位置
const endOffset = editorRangeRange.endOffset;
// 找出光标前的at符号位置
const textNodeValue = textNode.nodeValue;
const expRes = (/@([^@]*)$/).exec(textNodeValue);
if (expRes && expRes.length > 1) {
editorRangeRange.setStart(textNode, expRes.index);
editorRangeRange.setEnd(textNode, endOffset);
editorRangeRange.deleteContents(); // 删除联想草稿end
// 使用Button实现高亮文本
const btn = document.createElement('button');
btn.className = `createCodeNum`;
btn.textContent = `@${code}`;
// 高亮文本不可编辑
btn.contentEditable = 'false';
// 阻止光标切换到高亮文本内部
btn.addEventListener('click', () => {
return false
}, false);
// 不可选择
btn.tabIndex = -1;
// 空格字符,为了放光标方便
const bSpaceNode = document.createTextNode(' ');
insertHtmlAtCaret([btn, bSpaceNode], editorRange.selection, editorRange.range)
}
}
}
复制代码
替换后光标位置更新;
这一段其实我也不是太理解,有大佬看到还请不吝赐教,谢谢!
const insertHtmlAtCaret = ([btn, bSpaceNode], selection, range) => {
if (selection.getRangeAt && selection.rangeCount) {
if (selection.focusNode.parentNode.nodeName === 'BUTTON') return;
range.deleteContents();
const el = document.createElement("div");
el.appendChild(btn);
el.appendChild(bSpaceNode);
let frag = document.createDocumentFragment(), node, lastNode;
while ((node = el.firstChild)) {
lastNode = frag.appendChild(node);
}
range.insertNode(frag);
if (lastNode) {
range = range.cloneRange();
range.setStartAfter(lastNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
}
复制代码
- 浮窗点击
由于 click
和 blur
触发时机导致的问题,当浮窗显示状态下,点击浮窗列表项,触发顺序是先输入框 blur
再触发浮窗列表项的 click
,导致无法点到浮窗列表项;
如果显示浮窗时,直接点击其他区域失去焦点也不会save
,应该是触发save
,应该可以在document
监听点击处理,经与产品大大友好沟通,改为点击页面返回按钮进行保存,留下了这个坑;
const handleEditBlur = (e) => {
// 先触发Blur,再触发itemClick,不延迟会导致点不到
setTimeout(() => {
updateSearchFlag(false, null);
}, 300)
if (showSearchResult) {
// 浮窗显示时候,先触发Blur,再触发itemClick,此时失去焦点不请求接口save,因为点选浮窗时也会失去焦点
// 如果显示浮窗后直接点击其他区域失去焦点也不会save,应该是触发save,在document监听点击
} else {
// 获取编辑内容,请求接口保存
if (editableDivRef.current.textContent.length > MAXLENGTH) {
handleBlur(sliceOverText());
} else {
handleBlur(editableDivRef.current.innerHTML);
}
}
}
复制代码
- 浮窗位置计算
引入caret-pos
;
const getResultPosition = () => {
// 连带了scroll的位移
// 相对外层元素
// const pos = position(input); // { left: 15, top: 30, height: 20, pos: 15 }
// 相对视图
// const off = offset(input); // { left: 15, top: 30, height: 20 }
const pos = position(editableDivRef.current);
const off = offset(editableDivRef.current);
// 左右
const clientWidth = editableDivRef.current.clientWidth;
let toClientLeft = pos.left;
if (toClientLeft + TABLE_WIDTH >= clientWidth) {
toClientLeft = clientWidth - TABLE_WIDTH;
}
// 上下
const fixHeightGap = TITLE_HEIGHT + 2 + 15;
const clientHeight = document.scrollingElement.clientHeight;
const scrollTop = document.scrollingElement.scrollTop;
let toClientTop = pos.top;
if (off.top + TABLE_HEIGHT >= clientHeight + scrollTop) {
toClientTop = (TABLE_HEIGHT + off.height - fixHeightGap) * -1;
} else {
toClientTop = pos.top + fixHeightGap - scrollTop;
}
return {
left: toClientLeft,
top: toClientTop,
};
}
复制代码
- 字符长度计算
用户输入时候计算;
const validateKeyEvent = (e, callback) => {
if (notHandleKey.indexOf(e.key) !== -1 || compositionFlag) {
// 这些按键不做处理,如Shift
return;
}
const txt = editableDivRef.current.textContent;
const length = txt.length;
if (length >= MAXLENGTH && (canUseKey.indexOf(e.key) === -1)) {
// 中文输入法下无法阻止
// 失去焦点会导致无法删除
// editableDivRef.current.blur();
// 超出最大长度不能继续输入,部分按键不阻止默认事件
// setIsOverMaxLength(true);
const evt = e || window.event;
evt.preventDefault();
return false;
} else {
// setIsOverMaxLength(false);
setHasInputLength(length);
if (canSearch) {
callback(e);
}
}
}
复制代码
监听了四个事件;
const handleEditableKeyUp = (e) => {
validateKeyEvent(e, (e) => {
//
}
}
复制代码
const handleEditableKeyPress = (e) => {
validateKeyEvent(e, (e) => {
//
}
}
复制代码
const handleEditableKeyDown = (e) => {
validateKeyEvent(e, (e) => {
//
}
}
复制代码
const handleEditableMouseUp = (e) => {
validateKeyEvent(e, (e) => {
//
}
}
复制代码
历史输入内容回显时候计算;
// 有历史上记录时候回填后更新长度
useEffect(() => {
if (htmlStr !== null) {
const tempDom = document.createElement('div');
tempDom.contentEditable = "true";
tempDom.innerHTML = htmlStr;
let len = tempDom.textContent.length;
setHasInputLength(len);
} else {
setHasInputLength(0);
}
}, [htmlStr]);
复制代码
复制粘贴时候长度计算
const handleEditablePaste = (e) => {
// 拿到粘贴板内容
// @ts-ignore
let pastedText = (e.clipboardData || window.clipboardData).getData('text');
const rangeInfo = getEditorRange();
setEditorRange(rangeInfo);
if (rangeInfo && pastedText) {
const selectStringLen = rangeInfo.range.toString().length;
if (pastedText.length + hasInputLength - selectStringLen > MAXLENGTH) {
maxLengthTipFun();
} else {
// 删除选中内容
rangeInfo.range.deleteContents();
// 插入粘贴内容
const newTextNode = document.createTextNode(pastedText);
const bSpaceNode = document.createTextNode('');
insertHtmlAtCaret([newTextNode, bSpaceNode], rangeInfo.selection, rangeInfo.range);
}
}
// 隐藏浮窗
updateSearchFlag(false, null);
e.preventDefault();
return false;
}
复制代码
- 中文输入法长度计算
由于中文输入法的特殊性,需要判断下中文输入是否结束;
const handleCompositionEnd = (e) => {
// 控制中文输入时候搜索的时机
setCompositionFlag(false);
// 中文输入
let inputText = e.data;
const rangeInfo = getEditorRange();
setEditorRange(rangeInfo);
if (rangeInfo && inputText) {
const selectStringLen = rangeInfo.range.toString().length;
const afterEnterLen = inputText.length + hasInputLength - selectStringLen;
if (afterEnterLen > MAXLENGTH) {
const newHtmlStr = sliceOverText();
setTimeout(() => {
editableDivRef.current.innerHTML = newHtmlStr;
// 光标定位到最后
const childNodesArr = [...editableDivRef.current.childNodes]
rangeInfo.range.setStartAfter(childNodesArr[childNodesArr.length - 1]);
});
e.preventDefault();
return false;
} else {
setHasInputLength(afterEnterLen);
}
}
// 隐藏浮窗
updateSearchFlag(false, null);
}
复制代码
const handleCompositionStart = (e) => {
// 控制中文输入时候搜索的时机
setCompositionFlag(true);
}
复制代码
- 超长时候,阻止按键默认事件后
超长时候,通过阻止按键默认事件阻止继续输入,结果导致部分其他按键也被阻止,如上下左右等移动光标位置的按键,复制粘贴等;
所以在校验长度时候,对部分按键做了处理;
// 输入达到最大字符时仍可用的按键 k e y 值
const canUseKey = ['Backspace', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'End', 'Home'];
// 不处理的按键 k e y 值
const notHandleKey = ['Control', 'Shift', 'Alt', 'Unidentified', 'Escape', 'Tab'];
复制代码
- 中文输入法
计算字符长度的时机问题和触发搜索的时机问题;
const handleCompositionStart = (e) => {
// 控制中文输入时候搜索的时机
setCompositionFlag(true);
}
复制代码
const handleCompositionEnd = (e) => {
// 控制中文输入时候搜索的时机
setCompositionFlag(false);
...
}
复制代码
const handleSearchKey = (searchStr, rangeInfo) => {
if (!compositionFlag) {
// 中文输入完成后再搜索
getSearch && getSearch(key_words);
}
}
复制代码
- 复制粘贴
复制粘贴时候替换元素的处理,光标位置处理;
参见前面 复制粘贴时候长度计算;
- 已完成输入的查看预览
查看预览html
字符串,鼠标悬浮时候获取高亮文本,通过接口获取更多信息,用于浮窗显示;
方案:一种是转成node
后按子元素处理,另一种是按字符串处理;
这里选择了字符串处理方式,因为这样简单;
const handleHtmlStr = (htmlStr, contentType) => {
if (htmlStr === null) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} style={{ margin: '5px 0px 0' }} />;
}
// 处理字符串
const codeNumStart = htmlStr.split('<button class="createCodeNum" contenteditable="false" tabindex="-1">');
const result = codeNumStart.map((element, index) => {
if (element) {
if (element.indexOf('</button>') > -1) {
const codeNumArr = element.split('</button>');
const codeNumArr2 = codeNumArr[0].split('@');
const codeNum = codeNumArr2[1];
let codeInfo = '';
new Promise(async () => {
await api.queryCodeInfo(codeNum).then((res) => {
if (res && res.status === 200 && res.data) {
codeInfo = `...`;
}
})
.catch((err) => {
console.error(err);
})
.finally(() => {
})
});
return (
<Fragment>
<Tooltip
overlayStyle={{
maxWidth: '380px',
fontSize: '12px',
}}
title={() => { return codeInfo }}
>
<button className="createCodeNum">{`@${codeNum}`}</button>
</Tooltip>
<span dangerouslySetInnerHTML={{ __html: codeNumArr[1] }}></span>
</Fragment>
);
} else {
return <span dangerouslySetInnerHTML={{ __html: element }}></span>;
}
} else {
return '';
}
});
return result;
}
复制代码
总结
以上就是踩过的全部坑了,如果你也遇到类似的需求,请尽量友好的和产品大大商量换一个方案,如AntD
的 mentions
也不错啊,如果沟通失败,请谨慎评估工作难度和复杂度,不然。。。
当然如果是大佬看到了这里,还望不吝赐教,前端路漫漫,咱们一起看。