利用 contentEditable 属性,实现输入关键字时,将关键字转为标签设置样式展示,并支持快捷输入

604 阅读5分钟

前言

这是一个使用 contentEditable 为基础的输入框,支持识别关键字转换为标签以及快捷提示输入。

待优化

  1. 目前 Chrome、Edge 浏览器都是支持的,其他浏览器可能会有兼容问题;
  2. 快捷输入只支持 空格 选中,后续考虑加入点击选中;

需求

  1. 输入框,允许输入任意文本,单行展示,过滤空格;

  2. 当用户输入关键字后,需要将关键字展示为特殊样式;

    比如: 用户输入 123keyword456 时,keyword作为识别的关键字,处理后如下展示

图片.png

  1. 支持复制粘贴,注意粘贴时去除格式;
  2. 当用户输入 @ 符号时,弹出提示,按下空格自动填充关键字;

完整交互效果

需求示例动图

知识点

Selection 对象

  1. window.selection 用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。
  2. collapseToEnd 取消当前选区,并把光标定位在原选区的最末尾处,如果此时光标所处的位置是可编辑的,且它获得了焦点,则光标会在原地闪烁。

Range 对象

  1. selection.getRangeAt(0) 返回一个包含当前选区内容的区域对象

  2. range.setStart(dom, start) 设置选区开始位置

  3. range.setEnd(dom, end) 设置选区结束位置

  4. range.deleteContents() 删除选区内容

  5. range.insertNode(node) 在选区起始位置插入节点

  6. range.createContextualFragment(htmlStr) 可以将html文本转换为html节点

clipboardData 剪贴板

  1. (e.originalEvent || e)?.clipboardData 从事件对象获取剪贴板对象

  2. getData('text/plain') 获取指定格式的数据

组件引用示例


<KeywordInput

    width={300} // 组件宽度,默认100%

    value="123abc456" // 组件初始值,在 react + antd 中,可以作为 Form.Item 的 value 使用

    onChange={(data) => {}} // 组件值变化的回调,在 react + antd 中,可以作为 Form.Item 的 onChange 使用

    keyword="abc" // 关键字

    regexRule={/abc/g} // 关键字替换的规则,考虑到关键字中可能会出现正则的特殊字符,所以需要手动提供

    chartArr={['@']} // 快捷输入提示的触发字符

    addonBefore={'这是前缀'} // 输入框前缀

    leftConfig={{ content: '{', className: '' }} // 标签左侧连接符,content支持 string 或 返回值为字符串的函数

    rightConfig={{ content: () => '}', className: '' }} // 标签右侧连接符

    tagWrapClassName={'tag'} // 标签容器的 class

    trigger="blur" // onChange 函数的触发方式 默认值['blur','change']

/>

配置项列表

属性            说明                                                    类型                必填默认值            
value            值 (可作为初始数据)                                  string              否  -                
onChange        值变化的回调函数(在失去焦点时触发)                    function            否  -                
keyword          关键字                                                  string              是  -                
regexRule        正则表达式,在初始化渲染、粘贴时用于替换value中的关键字regex                是  -                
chartArr        触发快捷填充的字符集,按空格时自动插入标签                                    array                否  []                
addonBefore      输入框前缀                                              string/ () => void  否  -                
disabled        禁用                                                    boolean              否  false            
centerNode      标签中间的内容                                          string/ () => string否  keyword          
leftConfig      关键字左侧占位符配置                                    object              否  {}                
rightConfig      关键字右侧占位符配置                                    object              否  {}                
tagWrapClassName关键字转换标签的容器class名称                          string              否  -                
tipContent      快捷填充提示的内容                                      string /()=>void    否  keyword          
width            组件宽度                                                string /number      否  100%              
trigger          onChange的触发方式                                      ['blur','change']    否  ['blur','change']

leftConfig/rightConfig 配置说明

属性      说明                  类型              必填  默认值
content  内容                  string/()=>string否    null  
className占位符容器的class名称否                string-

代码

index.jsx

import React, { useRef, useEffect, useState, useMemo } from 'react';
import styles from './index.module.scss';
import { isFunction, max, min } from 'lodash';

const TRIGGER_MAP = {
  CHANGE: 'change', // 变化的时候
  BLUR: 'blur', // 失去焦点
};

  


// 判断 是否包含 关键字
function hasKeywords(str, keyword) {
  const node = document.createRange().createContextualFragment(str || '');
  if (node) {
    const childNodes = node.childNodes || [];
    for (let i = 0; i < childNodes.length; i++) {
      if (childNodes[i].nodeType === 3 && childNodes[i].data.includes(keyword)) {
        return true;
      }
    }
  }
}

//处理粘贴的文本,清除格式
function pasteText(event, dealText) {
  var e = event || window.event;
  // 阻止默认粘贴
  e.preventDefault();
  // 粘贴事件 clipboardData的属性,获取剪贴板的内容
  // clipboardData的getData(fomat) 获取指定格式的数据
  var text = (e.originalEvent || e)?.clipboardData?.getData('text/plain') || '';
  //清除回车、空格
  text = dealText(text.replace(/\[\d+\]|\n|\r|(?:&nbsp;)|\s/gi, ''));
  return text;
}

// 判断当前输入值是否允许展示提示
function triggerTipIndex(chartArr) {
  const selection = window.getSelection();
  const str = selection.focusNode.data || '';
  const index = selection.focusOffset - 1;
  const len = max(chartArr.map((c) => c.length)) - 1; //获取 charArr 中最大的字符长度,作为截取字符的最大值
  for (let i = len; i >= 0; i--) {
    // 从光标位置开始截取字符,依次判断是否在 与 charArr 数组匹配
    if (chartArr.includes(str.substring(index - i, index + 1))) {
      // 匹配,返回当前下标位置,以及匹配到的字符
      return { index, char: str.substring(index - i, index + 1) };
    }
  }
  // 未匹配到,返回 -1
  return { index: -1 };
}

// 获取 关键字 开头/结尾 所处的光标位置
function getClosestIndex(str = '', keyword, index = 0, step) {
  const arr = str.split('');
  const len = arr.length;
  // 根据 step 值,获取需要查找的字符,如果 -1 要找开头字符,1找结尾字符
  const char = (step === -1 ? keyword?.[0] : keyword[keyword.length > 0 ? keyword.length - 1 : 0]) || '';
  const commonCondition = arr[index] === char; //判断当前字符是否等于 查找的字符
  if (
    commonCondition &&
    ((step === -1 && str.substring(index, index + keyword.length) === keyword) ||
      (step === 1 && str.substring(index + 1 - keyword.length, index + 1) === keyword))
  ) {
    // 避免 关键字中出现重复字符,导致判断条件提前结束,所以增加判断条件
    // 当满足 commonCondition 时,且(-1时,向后截取 关键字长度 字符与关键字相等)/(1时,向前截取 关键字长度 字符与关键字相等),则说明找到了关键字的光标
    return step > 0 ? min([len, index + 1]) : max([0, index]);
  } else if (index >= 0 && index < len) {
    // 不满足条件,继续查找
    return getClosestIndex(str, keyword, index + step, step);
  }
}

const KeywordInput = ({
  value = '',
  onChange,
  keyword = '',
  regexRule = null,
  chartArr = [],
  addonBefore,
  disabled = false,
  centerNode = '',
  leftConfig = {
    content: '',
    className: '',
  },
  rightConfig = {
    content: '',
    className: '',
  },
  tagWrapClassName = '',
  tipContent = '',
  width = '100%',
  trigger = [TRIGGER_MAP.CHANGE, TRIGGER_MAP.BLUR],
}) => {
  const ref = useRef();
  const [isFocus, setIsFocus] = useState(false); // 是否获取焦点 (为了处理样式)
  const [inputTipVisible, setInputTipVisible] = useState(false); // 快捷输入提示显示状态

  useEffect(() => {
    if (!disabled) {
      ref.current.innerHTML = versionSpan(value); // 非禁用状态时,转换为标签;禁用状态不转换,只渲染value
    }
  }, [value]);

  const leftNode = useMemo(() => {
    return createNode(leftConfig); // 左侧连接节点
  }, [leftConfig]);

  const rightNode = useMemo(() => {
    return createNode(rightConfig); // 右侧连接节点
  }, [rightConfig]);

  const centerTagNode = useMemo(() => {
    // 标签内容
    return isFunction(centerNode) ? centerNode() : centerNode || keyword;
  }, [centerNode, keyword]);

  // 构建连接节点
  function createNode({ content, className }) {
    return `<span class="${styles.placeholder} ${className}">${
      content && (isFunction(content) ? content() : content)
    }</span>`;
  }

  // 粘贴
  function handlePaste(e) {
    const node = document.createElement('span');
    node.innerHTML = pasteText(e, versionSpan);
    const selection = window.getSelection();
    const range = selection.getRangeAt(0);
    // 删除当前选区的内容分
    range.deleteContents();
    // 设置光标位置
    range.setStart(selection.focusNode, selection.focusOffset);
    range.setEnd(selection.focusNode, selection.focusOffset);
    // 插入处理好的节点内容
    range.insertNode(node);
    // 取消当前选区,并把光标定位在原选区的最末尾处
    selection.collapseToEnd();
    e.preventDefault();
    return false;
  }

  // 获取焦点
  function handleFocus() {
    setIsFocus(true);
  }

  // 失去焦点
  function handleBlur() {
    setIsFocus(false);
    if (trigger.includes(TRIGGER_MAP.BLUR)) {
      // 触发 onChange
      isFunction(onChange) && onChange(getContentEditableValue());
    }
  }

  // 按下事件
  function handleKeyDown(e) {
    if ([13, 32].includes(e.keyCode)) {
      // 过滤空格 换行
      e.preventDefault();
    }
  }

  // 按键抬起
  function handleKeyUp(e) {
    // 输入快捷提示
    changeInputTip();
    if (e.keyCode === 32 && inputTipVisible) {
      // 当快捷提示显示,且按了空格时,在当前位置插入 标签
      const selection = window.getSelection();
      // 获取 触发显示的字符长度。用于确定选区的位置
      const charLen = triggerTipIndex(chartArr)?.char?.length || 0;
      // 更新选区,插入节点
      updateRange(selection.focusNode, selection.focusOffset - charLen, selection.focusOffset);
      // 关闭提示
      setInputTipVisible(false);
    }
    const value = getContentEditableValue('HTML');
    if (hasKeywords(value, keyword)) {
      // 当输入内容中有 关键字 则替换为 span节点
      const selection = window.getSelection();
      let offset = selection.focusOffset;
      const start = getClosestIndex(
        selection.focusNode.data,
        keyword,
        min([offset + 1, selection.focusNode.data.length - 1]), // offset + 1 查找开始字符从光标位置后一位开始找,更保险一些。避免超出字符长度,所以加了min处理
        -1
      );
      const end = getClosestIndex(selection.focusNode.data, keyword, max([offset - 1, 0]), 1); // offset - 1 ,查找结尾的时候,从当前光标前一位找
      updateRange(selection.focusNode, start, end);
    }
    if (trigger.includes(TRIGGER_MAP.CHANGE)) {
      isFunction(onChange) && onChange(getContentEditableValue());
    }
  }

  // 输入提示显示/隐藏
  function changeInputTip() {
    const index = triggerTipIndex(chartArr)?.index;
    setInputTipVisible(index >= 0);
  }

  function updateRange(dom, start, end) {
    const selection = window.getSelection();
    const range = selection.getRangeAt(0);
    range.setStart(dom, start);
    range.setEnd(dom, end);
    range.deleteContents();
    const node = document.createElement('span');
    node.contentEditable = false;
    node.className = `${styles.contentEditableSpan} ${tagWrapClassName}`;
    node.innerHTML = `${leftNode}${centerTagNode}${rightNode}`;
    range.insertNode(node);
    selection.collapseToEnd();
  }

  // 替换关键字为span
  function versionSpan(str) {
    const span = `<span class="${styles.contentEditableSpan} ${tagWrapClassName}" contentEditable="false">${leftNode}${centerTagNode}${rightNode}</span>`;
    return str.replace(regexRule, span);
  }

  // 获取 contentEditable div 的值
  function getContentEditableValue(type = 'text') {
    return ref.current[type === 'text' ? 'innerText' : 'innerHTML'];
  }


  return (
    <div className={`${styles.wrap}`} style={{ width: width }}>
      {disabled && <div className={styles.disabled}>{value}</div>}
      {!disabled && (
        <>
          <div className={styles.content}>
            {addonBefore && (
              <div className={`${styles.addonBefore}`}>{isFunction(addonBefore) ? addonBefore() : addonBefore}</div>
            )}
            <div className={`${styles.inputWrap} ${isFocus ? styles.focus : ''}`}>
              <div
                ref={ref}
                contentEditable
                id="contentEditable"
                className={styles.input}
                onBlur={handleBlur}
                onFocus={handleFocus}
                onKeyDown={handleKeyDown}
                onKeyUp={handleKeyUp}
                onPaste={handlePaste}
              ></div>
            </div>
            {inputTipVisible && (
              <div className={styles.inputTip}>{isFunction(tipContent) ? tipContent() : tipContent || keyword}</div>
            )}
          </div>
        </>
      )}
    </div>
  );
};
export default KeywordInput;

index.module.scss

.wrap {
    width: 100%;
    line-height: 32px;
    
    .addonBefore {
        border: 1px solid #d9d9d9;
        border-right: none;
        padding: 0 12px;
        background-color: #fafafa;
        word-break: break-all;
        white-space: nowrap;
    }

    .inputWrap {
        width: 100%;
        flex-grow: 1;
        padding: 0 12px;
        border: 1px solid #d9d9d9;
        display: flex;
        overflow-y: auto;
        border-radius: 2px;
        border-top-left-radius: 0;
        border-bottom-left-radius: 0;

        &:hover {
            border-color: #aec;
        }

        &.focus {
            border-color: #aec;
            box-shadow: 0 0 0 2px #aaeecc33;
        }

        .contentEditableSpan {
            padding: 2px 4px;
            border: 1px solid #ececec;
            border-radius: 2px;

            .placeholder {
                color: rgb(255, 102, 1);
            }
        }
    }

    .input {
        width: 100%;
        word-wrap: normal;
        overflow-y: auto;
        white-space: nowrap;
        -webkit-user-modify: read-write-plaintext-only;

        &::-webkit-scrollbar {
            width: 0;
            height: 0;
        }

        &:focus-visible {
            border: none;
            outline: none;
        }
    }

    .disabled {
        border: 1px solid #d9d9d9;
        width: 100%;
        color: rgba(0, 0, 0, 0.25);
        background-color: #f5f5f5;
        cursor: not-allowed;
        opacity: 1;
        padding: 0 11px;
    }

    .content {
        width: 100%;
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: flex-start;
        position: relative;

        .inputTip {
            line-height: 24px;
            border: 1px solid #ededed;
            background-color: #fff;
            padding: 0 4px;
            margin: 0 4px;
            position: absolute;
            bottom: -30px;
            right: 45%;
            border-radius: 4px;
            box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
                0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
  
            &::before {
                content: '';
                width: 8px;
                height: 8px;
                background-color: #fff;
                transform: rotate(45deg);
                border-top: 1px solid #ededed;
                border-left: 1px solid #ededed;
                position: absolute;
                top: -4px;
                left: 50%;
                margin-left: -6px;
            }
        }
    }
}