看似简单的@联想功能踩坑经验分享

我正在参与掘金创作者训练营第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>
复制代码

踩坑记录

  1. 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 = '';
        }
    }
}
复制代码
  1. 联想浮窗

然后是联想浮窗,依然是问题重重,哭唧唧。。。

这里没有实现按键控制选择联想结果,放在下一期了,实在肝不过来了,感谢产品大大不杀之恩;

  • 点选联想结果替换成高亮文本

选取@+联想关键字,替换为高亮文本;

高亮文本使用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);
    }
  }
}
复制代码
  • 浮窗点击

由于 clickblur 触发时机导致的问题,当浮窗显示状态下,点击浮窗列表项,触发顺序是先输入框 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,
  };
}
复制代码
  1. 字符长度计算

用户输入时候计算;

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);
}
复制代码
  1. 超长时候,阻止按键默认事件后

超长时候,通过阻止按键默认事件阻止继续输入,结果导致部分其他按键也被阻止,如上下左右等移动光标位置的按键,复制粘贴等;

所以在校验长度时候,对部分按键做了处理;

// 输入达到最大字符时仍可用的按键 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'];
复制代码
  1. 中文输入法

计算字符长度的时机问题和触发搜索的时机问题;

const handleCompositionStart = (e) => {
    // 控制中文输入时候搜索的时机
    setCompositionFlag(true);
}
复制代码
const handleCompositionEnd = (e) => {
    // 控制中文输入时候搜索的时机
    setCompositionFlag(false);
    ...
}
复制代码
const handleSearchKey = (searchStr, rangeInfo) => {
    if (!compositionFlag) {
        // 中文输入完成后再搜索
        getSearch && getSearch(key_words);
    }
}
复制代码
  1. 复制粘贴

复制粘贴时候替换元素的处理,光标位置处理;

参见前面 复制粘贴时候长度计算;

  1. 已完成输入的查看预览

查看预览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;
}
复制代码

总结

以上就是踩过的全部坑了,如果你也遇到类似的需求,请尽量友好的和产品大大商量换一个方案,如AntDmentions也不错啊,如果沟通失败,请谨慎评估工作难度和复杂度,不然。。。

当然如果是大佬看到了这里,还望不吝赐教,前端路漫漫,咱们一起看。

猜你喜欢

转载自juejin.im/post/7068608197057052680