太难了!使用div+contentEditable实现四则运算前端小白踩坑日记

515 阅读2分钟

需求背景

最近写项目碰到一个描述简单,但实现起来比较复杂的需求,且产品还没给具体实现方案。即:前端实现选择‘因子’(指标)配置公式,公式要求:四则运算+括号。借鉴网上各位前辈经验制定了如下实现方式:
1.输入'$'弹出弹框选择因子.

2.选择因子回填,根据需求输入+, -, *, /, (, ). 3.因子可整个删除

image.png

技术栈

umi4.0 + antd4.0 + js原生(90%)

写在前面

本人是一枚刚入行不久的前端新手,这个小功能断断续续花了三天左右才实现,写这篇文章的目的是回顾梳理下实现这个输入框的实现过程,并且希望能给实现类似功能焦头烂额的你提供一点思路,如有问题,欢迎指正。

所需知识

  • 给div标签设置contenteditable={true}会使其变为可编辑html
  • onCompositionStart 在输入中文开始时触发, onCompositionEnd 在输入中文结束时触发; ⚠️:这是非常重要的一点,只有onCompositionEnd才能拿到在中文输入法下的值,input则拿不到
  • 一些 正则 知识;
  • Window.getSelection();
  • dom.dispatchEvent(new Event('input', { bubbles: true }));手动触发input

正文

1.给div标签加上contenteditable

    <div
        className='frontLineItem'
        contentEditable
        ref={divRef}
        onClick={getRecordCoordinates}
        onKeyDown={keydownEv}
        onCompositionEnd={handleChineseInput}
        onInput={handleInputValue}
        onPaste={(e) => { e.preventDefault() }}
    />
    .frontLineItem {
      position: relative;
      border: 1px solid #d9d9d9;
      padding: 5px;
      height: 80px;
      outline: none;
      border-radius: 2px;

      // 利用伪元素伪造placeholder
      &:empty::after {
        content: '允许输入数字, $, +-*/ ()';
        color: #d9d9d9;
        position: absolute;
        top: 4px;
        left: 8px;
      }
    }
    
  1. 获取光标位置
    const getRecordCoordinates = () => {
      try {
        const selection = window.getSelection() as Selection;
        const range = selection?.getRangeAt(0);
        lightPosition.current = {
          selection,
          range, // 返回range对象
        };
        console.log('totalRange',lightPosition.current.range,selection)
      } catch (error) {
        throw new Error('获取光标位置失败');
      }
    };

  1. 监听键盘输入事件
    const keydownEv = (e: KeyboardEvent<HTMLDivElement>) => {
      if (e.key === '$') {
        e.preventDefault();
        getRecordCoordinates();
        Modal.confirm(modalConfig);
      } else {
        const allowReg = /^[0-9.+\-*/()]|(Backspace)|(ArrowLeft)|(ArrowRight)|(ArrowDown)|(ArrowUp)|(Shift)+$/;
        if (!allowReg.test(e.key)) {
          e.preventDefault();
        }
      }
    };

4.监听中文输入,在中文输入完成时将输入的中文截掉

    const handleChineseInput = (e: CompositionEvent<HTMLDivElement>) => {
      const curChinese = e.data;
      // 会造成光标到前面
      divRef.current!.innerHTML = divRef.current!.innerHTML.replace(curChinese, '');

      // 处理边缘情况:input为空时输入中文
      divRef.current!.dispatchEvent(new Event('input', { bubbles: true }));

      // 光标到最后(TODO: 光标定位)
      const { range, selection } = lightPosition.current!;
      range?.selectNodeContents(divRef.current!);
      range?.collapse(false);
      selection?.removeAllRanges();
      selection?.addRange(range!);
      // 更新光标
      getRecordCoordinates();
    }

5.弹框的配置(此处用的是antd组件,可根据需求自行更改)

    const modalConfig: ModalFuncProps = {
      icon: null,
      title: <div style={{ marginBottom: 10 }}>&nbsp;&nbsp;&nbsp;因子选择</div>,
      content: <Form form={factorForm} colon={false}>
        <Form.Item label=' ' name='factorGuid'>
          <Select placeholder='选择因子'>
            {
              fackedFactorObjs.map((f) => (
                <Option key={f.id} value={f.id}>
                  {f.name}
                </Option>
              ))
            }
          </Select>
        </Form.Item>
      </Form>,
      onOk() {
        const { selection, range } = lightPosition.current!;
        const inputNode = document.createElement('input');
        inputNode.value = `${fackedFactorObjs.find((f) => f.id === factorForm.getFieldValue('factorGuid'))!.name}`; // $的文本信息
        inputNode.type = 'button';
        inputNode.dataset.id = `${factorForm.getFieldValue('factorGuid')}`; // 用户ID、为后续解析富文本提供
        inputNode.className = 'tag';

        const frag = document.createDocumentFragment();
        frag.appendChild(inputNode);
        if (range) {
          range.insertNode(frag);
        }

        // 解决插入结点不触发input事件
        divRef.current!.dispatchEvent(new Event('input', { bubbles: true }));

        selection.removeAllRanges(); // 移除当前光标
        selection.addRange(range); // 还原光标位置
        selection.collapseToEnd(); // addRange之后光标处于选中状态,需要将光标移动至最末端
        getRecordCoordinates(); // 更新存储的光标信息
      },
    };
  1. 由于该组件要配合antd的Form使用,antd官网的描述如下

image.png onChange可以实现,即form.getFeildsvalue()不受影响。由于div没有value属性,需用dangerouslySetInnerHTML代替,但如此就会引发新的问题,每次输入都会导致光标前移,故而给出父组件传入ref拿到整个dom的方法来弥补不能form.setFeildsValue()的缺陷

写在后面

实现该组件的目的是为了实现一个表单控件传值给后端,不同的后端需要不同的数据结构,可参照我的处理方式这里,这里也就不细说了。
点击这里查看源码