实现一个可插入标签的输入框

8,104 阅读5分钟

缘由

故事从我在coding的时候,突然又被拉入了一个群开始....

很快产品经理就开始了需求评审,及其专业的术语描述着整个需求的背景以及实现后带来的收益,可是这我关心吗,把会议挂在一旁继续修昨天写的bug,抽看看了眼技术文档:好像不难,无非就是增删查改,回头在看文档吧,很快到了提问环节。产品:前端还有问题吗?我:没有,没有。

很快需求上了排期,打开文档仔细阅读一番,其他需求看着都挺简单,突然一个看似正常又不太常见的输入框出现在需求上,原来是要实现一个可插入自定义标签的输入框,后续还需要进行展示,对于标签是动态数据。翻阅公司组件库,我当场就不乐意了赶紧找来产品可否进行需求降级,把可插入标签的输入框变成全插入标签的输入框,这不就简单多了,可以直接用InputTag:

image.png

显然这不符合产品的要求,我们来看一下最终做出来的效果:

20220807093856.gif

点击插入因子可以插入标签内容,这里我用写死的文本代替了,同时支持任意位置文本的输入,对于标签支持删除操作。

预先知识

contenteditable

要实现这个效果我想就是通过修改元素的可编辑性,使之可以编辑,html就给我们提供了这么个属性,要知道在一个普通元素内插入一个标签要比在textarea中要容易多了。

-webkit-user-modify

这是个css属性,通过对应属性的设置也可以达到同样的效果。

read-only: 默认值,元素只读,不可编辑;

read-write: 可以编辑,支持富文本;

read-write-plaintext-only: 可以编辑,不支持富文本;

write-only: 使元素仅用于编辑(几乎没有浏览器支持)

Range对象

由于产品还需要对应的插入修改可以根据光标的位置来定,所以我们还需要这么一个对象。

可以用 Document 对象的 Document.createRange 方法创建 Range,也可以用 Selection 对象的 getRangeAt 方法获取 Range。另外,还可以通过 Document 对象的构造函数 Range() 来得到 Range。我在实现的时候通过的是selection对象(表示用户选择的文本范围或插入符号的当前位置)的方法创建的,通过监听selectionchange事件来响应式的更新我的range,这样我就可以定位到光标的位置,那么对于标签插在哪的问题就解决了。

代码实现

这里我用的是react实现的,我先把所有代码粘贴上来,下面在进行解释

import React, { useEffect, useRef, useState } from 'react'
import { Button } from "@ecom/auxo";
import "./index.css";
type Props = {
  callBack: Function;
}

export default function App({
  callBack
}: Props) {

  const handleInput = () => {
    callBack((inputTag as any).current.innerHTML);
  }
  // 鼠标焦点对象
  const [Range, saveRange] = useState<Range>();
  const getGuid=()=> {
    // 生成随机ID
    return `r${new Date().getTime()}d${Math.ceil(Math.random() * 1000)}`;
  }
  const [contentId,setContentId] = useState(`content${getGuid()}`)
  const selecthandler = () => {
    // 监听选定文本的移动
    let sel = window.getSelection();
    let range = sel ? sel.rangeCount > 0 ? sel?.getRangeAt(0) : null : null;
    if (range && range.commonAncestorContainer.ownerDocument?.activeElement?.id === contentId) {
      saveRange(range);
    }
  }
  useEffect(() => {
    document.addEventListener('selectionchange', selecthandler);
    return () => {
      document.removeEventListener('selectionchange', selecthandler);
    }
  }, [])

  const inputTag = useRef<HTMLDivElement>(null);
  const insertNode = (node: Element) => {
    // 删掉选中的内容(如有)
    Range && Range.deleteContents();
    // 插入链接
    Range && Range.insertNode(node);
    // 更新内容  
    callBack((inputTag as any).current.innerHTML);
  }
  // 添加标签
  const addTag = (text: string) => {
    let node = document.createElement('wise');
    node.innerText = text;
    const cancelNode = document.createElement("span");
    cancelNode.innerText=("✕");
    cancelNode.style.color="black";
    cancelNode.onclick=(even)=>{
      inputTag.current?.removeChild((even.target as any).parentElement);
      callBack((inputTag as any).current.innerHTML);
    }
    node.append(cancelNode);
    insertNode(node);
    setDisabled(true);
  }
  // 添加因子
  const addfactor = () => {
    addTag("<div>test</div>");
  }
  // 是否可以添加因子
  const [disabled,setDisabled]=useState(true);
  return (
    <div
    className='tagTextArea'
    >
      <div
        contentEditable="true"
        className='myTextArea'
        onInput={handleInput}
        ref={inputTag}
        id={contentId}
        onFocus={()=>{
          setDisabled(false);
          console.log("focus")
        }}
      >
      </div>
      <Button disabled={disabled} onClick={addfactor} type='dashed'>插入因子</Button>
    </div>
  )
} 

onInput事件监听

const handleInput = () => { callBack((inputTag as any).current.innerHTML); }

在这里我们通过父组件传递下来的回调函数把最终输入框的内容传递出去,所以每当输入框内容改变的时候就会出发,保证数据的实时性。

getGuid

const getGuid=()=> { 
// 生成随机ID 
return `r${new Date().getTime()}d${Math.ceil(Math.random() * 1000)}`;
}

这里我们借助时间搓来返回一个随机id用于确定每个输入框对象,因为我们的页面可能不止一个输入框,如果存在多个输入框那么在定位光标的时候,区分按钮对应的输入框就要用到这个随机的ID值。

副作用

useEffect(() => { document.addEventListener('selectionchange', selecthandler);
 return () => { document.removeEventListener('selectionchange', 
 selecthandler); } }, [])

在程序一开始时,我们就要监听光标的位置改变,为了定位标签插入的位置,在销毁的时候取消监听。

selecthandler事件

  const selecthandler = () => {
    // 监听选定文本的移动
    let sel = window.getSelection();
    let range = sel ? sel.rangeCount > 0 ? sel?.getRangeAt(0) : null : null;
    if (range && range.commonAncestorContainer.ownerDocument?.activeElement?.id === contentId) {
      saveRange(range);
    }
  }

这里我们是处理光标改变后的位置存储,这里不能直接存储,因为前面说了我们可能会有多个输入框出现在一个页面中,所以我们要用唯一的ID进行判断,只有是当前输入框的位置改变了我们才进行存储。

addfactor->addTag->insertNode

  const insertNode = (node: Element) => {
    // 删掉选中的内容(如有)
    Range && Range.deleteContents();
    // 插入链接
    Range && Range.insertNode(node);
    // 更新内容  
    callBack((inputTag as any).current.innerHTML);
  }
  // 添加标签
  const addTag = (text: string) => {
    let node = document.createElement('wise');
    node.innerText = text;
    const cancelNode = document.createElement("span");
    cancelNode.innerText=("✕");
    cancelNode.style.color="black";
    cancelNode.onclick=(even)=>{
      inputTag.current?.removeChild((even.target as any).parentElement);
      callBack((inputTag as any).current.innerHTML);
    }
    node.append(cancelNode);
    insertNode(node);
    setDisabled(true);
  }
  // 添加因子
  const addfactor = () => {
    addTag("<div>test</div>");
  }

在添加因子按钮这里我是写死数据,正常应该通过后端接口获取来动态插入,至于标签那还需要什么样式或者功能展示,我们都可以通过append来进行追加,我这里是加入了删除标签的功能,实现也是比较简单,监听点击事件,通过removeChild方法进行删除就可以了。

css部分

.myTextArea{
    -webkit-user-modify: read-write-plaintext-only !important;
    border:1px solid #ccc;
    overflow: hidden;
    box-sizing: border-box;
    word-break: break-word;
    height: 200px;
    width: 200px;
}
wise {
    background-color: #f0f6fe;
    color: #5387f7;
    padding: 0 1px;
    border-radius: 2px;
    /* white-space: nowrap; */
    cursor: default;
    -webkit-user-modify: read-only !important;
  }

这里用到了我们的主角-webkit-user-modify:,通过他的设置使得我们的标签可以编辑,这里还有一个小技巧就是wise标签的设置,这个就是我们的标签名字,这样我们就可以通过标签样式统一设置我们的标签,同时通过-webkit-user-modify: read-only !important;的设置,在删除的时候也是一整个的删除。

最后

至此我们的功能已经实现了,这里只是一个demo提供一种思路,里面还有很多anyscript,真要开发这样一个组件还需要考虑很多的兼容性。