【前端功能点】图片输入框

547 阅读5分钟

需求整理

  • 支持输入文字、图片
  • 支持复制、粘贴功能
  • 支持长度限制、发送功能
  • 支持添加前缀、后缀

实现思路整理

  • 使用div标签作为输入控件(原生的input是无法实现输入图片并展示在input内)
    1. 添加contentEditablediv标签可编辑
         // suppressContentEditableWarning 去除警告
         <div contentEditable suppressContentEditableWarning />
      
    2. 设置tabIndex属性值为0div标签可聚焦
       <div tabIndex={0} />
      
    3. 设置id(唯一标识), ref(保存当前div节点的内容),以及设置样式(看起来更像一个输入框)
  • 要支持输入图片以及长度限制,那就需要两个变量分别去保存输入框的value和字符length
    1. 使用ref保存上面的值
    import React from 'react';
    const infoRef = React.useRef({ value: '', count: 0 })
    
    1. 要控制字符长度,需要在合适的时机阻止输入框默认的输入事件
    // maxLength 限制输入框可输入的最大字符长度
    const onBeforeInput = (e) => {
        if (maxLength && maxLength <= infoRef.current.count) {
          e.preventDefault(); // 阻止默认事件
        }
    };
    // 通过这个事件控制达到最大长度时禁止输入
    <div onBeforeInput={onBeforeInput} />
    
  • 插入图片功能,这里有四个核心问题要梳理清楚
  1. 插入图片时,输入框是否聚焦?
  2. 是否选中了输入框中内容
  3. 是否选中了输入框外内容
  4. 插入到什么位置(也就是光标如何定位)?
  5. 具体实现
    // 首先要解决的是光标定位问题,这里可以使用 window.getSelection 这个api
    // 这里还要考虑是否选中了输入框中的内容,选中的内容要被覆盖掉
    // 输入框聚焦时获取选择内容,失焦时也要保存下光标内容
    import React from 'react';
    const rangeRef = React.useRef(null); // 保存选中的内容
    // 组件卸载时清空
    React.useEffect(
        () => () => {
          if (rangeRef.current) {
            rangeRef.current = null;
          }
        },
        [],
    );
    // 聚焦时获取光标位置以及选中内容
    const handleFouces = () => {
        const sel = window.getSelection();
        if (rangeRef.current) {
            // 如果记录的光标的位置就复位
            sel.removeAllRanges();
            sel.addRange(rangeRef.current)
        } else {
            // 未记录位置,置光标到末尾
            sel.selectAllChildren(divRef.current)
            sel.collapseToEnd(); // 取消当前选区,并把光标定位在原选区的最末尾处
        }
    }
    // 失去焦点时记录光标的所在的范围
    const handleBlur = () => {
          const sel = window.getSelection();
          const range = sel.getRangeAt(0);
          rangeRef.current = range;
    }; 
    // 插入图片的操作,该方法添加到要插入的图片上
    const inset = (url, ...args) => {
        // 忽略一些非空判断
        // 手动聚焦
        handleFouces();
        const sel = window.getSelection();
        // 获取当前光标所在的范围
        const range = sel.getRangeAt(0);
        rangeRef.current = range;
        const isCollapsed = sel.isCollapsed;// 选中的范围起止点是否在同一个位置
        // 如果选中有值,先清除
        if (sel.rangeCount > 0) {
          rangeRef.current.deleteContents();
        }
        // 创建图片节点
        let img: HTMLImageElement = document.createElement('img');
        img.src = url; // 这里先忽略其他属性
         // 将img元素插入到选中节点中
        rangeRef.current.insertNode(img);
        // 将选中范围的开始坐标放在img之后
        rangeRef.current.setStartAfter(img);
        // 将选中范围的结束坐标设置和开始坐标一致
        rangeRef.current.collapse(true);
        img = null;
        // 重新获取选中
        const sele = getSelection();
        sele.getRangeAt(0);
        // 删除所有选中范围
        sele.removeAllRanges();
        // 添加当前选中范围为 rangeRef.current
        sele.addRange(rangeRef.current);
        // 这里就需要,添加value、长度计算
        // 因为输入框本身的删除、剪切、粘贴等操作均能影响字符长度,故这里直接循环div节点的子节点计算
        getValue() // 为了方便,直接写在下面了
        
    }
    // 得到value,并赋值给 infoRef
    const getValue = () => {
        if (divRef.current) {
          infoRef.current.count = 0;
          let v = '';
          for (let i = 0, len = divRef.current.childNodes.length; i < len; i += 1) {
            // 节点
            const el: any = divRef.current.childNodes[i];
            if ((el.nodeName as string).toLocaleLowerCase() === '#text') {
              // 文本节点直接拼接
              v += el.nodeValue;
              infoRef.current.count += el.nodeValue.length;
            } else if ((el.nodeName as string).toLocaleLowerCase() === 'img') {
              // 图片节点
              v += `[!${el.getAttribute('src')}]`;
              infoRef.current.count += 1;
            }
          }
          infoRef.current.value = v;
        }
    };
    
    1. 到这里图片插入的功能基本实现,接下来还要处理输入事件
     // 输入事件只需要重新获取下value、和length即可,本身就支持文字输入的
      const handleInput = (e) => {
        try {
          if (e && e.nativeEvent) {
            getValue();
          }
        } catch (error) {
          console.error(error);
        }
      };
    
  • 重写复制方法,关键apiclipboardData
// 重写复制的方法
 const handleCopy = (e) => {
    e.preventDefault();
    const sl = window.getSelection();
    if (sl.rangeCount !== 0) {
      let box = document.createElement('div');
      box.appendChild(sl.getRangeAt(0).cloneContents());
      const html = box.innerHTML;
      const isImg = html && html.match(/<img.*>/);
      if (isImg) {
        e.clipboardData.setData('text/html', html);
      } else {
        e.clipboardData.setData('text/plain', html);
      }
      box = null;
    }
 };
  • 重写粘贴方法
 const handlePaste = (e) => {
    // 阻止默认事件
    e.preventDefault();
    // 获取剪切板内容
    let clipboardData =
      e.clipboardData || (e.originalEvent || {}).clipboardData || (window as any).clipboardData;
    let pasteHtml = clipboardData.getData('text/html');
    // 如果复制的是文本,也拼接成html处理
    if (!pasteHtml) {
      const pasteText = clipboardData.getData('text/plain');
      pasteHtml = 
      `<html><body><!--StartFragment--><div data-zone-id="0" data-line-index="0" data-
      line="true" style="white-space: pre-wrap;">${pasteText}</div><!--EndFragment--></body></html>`;
    }
    // 调用失去焦点的方法是为了获取当前光标对应的选中位置
    handleBlur();
    if (pasteHtml) {
        // 获取dom解析的实例
      let paster: any = new DOMParser();
      // 将字符串解析成dom树节点
      let doc = paster.parseFromString(pasteHtml, 'text/html');
      // 调用插入的方法
      insetEmoji({ url: true }, () => {
          // 先获取下字符长度
        getValue();
        // 循环插入节点,这个方法在最后全部代码中会有体现
        loopNode(doc);
      });
      paster = null;
      doc = null;
    }
    clipboardData = null;
  };

到这里图片输入框的核心逻辑基本完成

全部代码

import React from 'react';
export interface ExpandData {
  key: string;
  dom: ReactElement;
}
export interface EmojiInputProps extends BaseComponent {
  id?: string;
  prefix?: ReactElement;
  afterfix?: ReactElement;
  iptClassName?: string;
  expandClassName?: string;
  imgClassName?: string;
  crossOrigin?: HTMLImageElement['crossOrigin'];
  expandKeys?: ExpandData[];
  maxLength?: number;
}
const EmojiInput: React.FC<EmojiInputProps> = (props) => {
  const {
    className,
    iptClassName,
    prefix,
    afterfix,
    id = 'e_s_y-emoji-ipt',
    expandClassName,
    imgClassName,
    expandKeys,
    maxLength,
    crossOrigin,
  } = props;
  const divRef = React.useRef<HTMLDivElement>(null);
  const rangeRef = React.useRef(null);
  const [expandKey, setExpandKey] = React.useState(null);
  const infoRef = React.useRef({ value: '', count: 0 });
  const expandDom = Array.isArray(expandKeys) ? expandKeys.find((it) => it.key === expandKey) : null;
  React.useEffect(
    () => () => {
      if (rangeRef.current) {
        rangeRef.current = null;
      }
    },
    [],
  );
  // 失去焦点时记录光标的位置
  const handleBlur = () => {
    try {
      const sel = window.getSelection();
      const range = sel.getRangeAt(0);
      rangeRef.current = range;
    } catch (error) {
      console.error(error);
    }
  };
  // 获取焦点时的光标处理
  const handleFouces = () => {
    try {
      const sel = window.getSelection();
      if (rangeRef.current) {
        // 如果记录的光标的位置就复位
        sel.removeAllRanges();
        sel.addRange(rangeRef.current);
      } else {
        // 未记录位置,置光标到末尾
        sel.selectAllChildren(divRef.current);
        sel.collapseToEnd();
      }
    } catch (error) {
      console.error(error);
    }
  };

  // 得到value,并赋值给 infoRef
  const getValue = () => {
    if (divRef.current) {
      infoRef.current.count = 0;
      let v = '';
      for (let i = 0, len = divRef.current.childNodes.length; i < len; i += 1) {
        // 节点
        const el: any = divRef.current.childNodes[i];
        if ((el.nodeName as string).toLocaleLowerCase() === '#text') {
          // 文本节点直接拼接
          v += el.nodeValue;
          infoRef.current.count += el.nodeValue.length;
        } else if ((el.nodeName as string).toLocaleLowerCase() === 'img') {
          // 图片节点
          v += `[!${el.getAttribute('src')}]`;
          infoRef.current.count += 1;
        }
      }
      infoRef.current.value = v;
    }
  };

  // 输入事件
  const handleInput = (e) => {
    try {
      if (e && e.nativeEvent) {
        getValue();
      }
    } catch (error) {
      console.error(error);
    }
  };

  const insertImg = (url, alt, cn, isCollapsed = false) => {
    // 最大长度限制
    if (isCollapsed && maxLength && maxLength <= infoRef.current.count) {
      return;
    }
    let img: HTMLImageElement = document.createElement('img');
    if (crossOrigin) {
      img.crossOrigin = crossOrigin;
    }
    img.className = cn;
    img.src = url;
    img.alt = alt;
    img.style.maxWidth = '25%';
    img.draggable = false;
    img.onclick = (e) => {
      // 点击图片时设置光标位置
      let imgDom: HTMLImageElement = e.target as any;
      rangeRef.current = null;
      const rg = document.createRange();
      rg.selectNodeContents(imgDom);
      const sl = window.getSelection();
      rg.setStartAfter(imgDom);
      sl.removeAllRanges();
      sl.addRange(rg);
      imgDom = null;
      rangeRef.current = rg;
    };
    // 将img元素插入到选中节点中
    rangeRef.current.insertNode(img);
    rangeRef.current.setStartAfter(img);
    rangeRef.current.collapse(true);
    img = null;
  };

  const insetEmoji = (item, cb) => {
    try {
      if (item && item.url && divRef.current) {
        // 手动让输入框聚焦
        handleFouces();
        const sel = window.getSelection();
        if (rangeRef.current) {
          // 如果选中的不是输入框就清除
          while (
            rangeRef.current.commonAncestorContainer &&
            rangeRef.current.commonAncestorContainer.parentNode &&
            rangeRef.current.commonAncestorContainer.id !== id &&
            rangeRef.current.commonAncestorContainer.parentNode.id !== id
          ) {
            sel.selectAllChildren(divRef.current);
            sel.collapseToEnd();
          }
        }
        // 获取当前光标的位置
        const range = sel.getRangeAt(0);
        rangeRef.current = range;
        const isCollapsed = sel.isCollapsed;
        // 清除选择的内容
        if (sel.rangeCount > 0) {
          rangeRef.current.deleteContents();
        }
        // 插入元素
        if (typeof cb === 'function') {
          cb();
        } else {
          insertImg(item.url, item.alt, [图片样式自己传]), isCollapsed);
        }
        const sele = getSelection();
        sele.getRangeAt(0);
        sele.removeAllRanges();
        sele.addRange(rangeRef.current);
        getValue();
      }
    } catch (error) {
      console.error(error);
    }
  };

  const onBeforeInput = (e) => {
    const sel = window.getSelection();
    if (sel.isCollapsed && maxLength && maxLength <= infoRef.current.count) {
      e.preventDefault();
    }
  };

  // 清除到初始化状态
  const onClear = () => {
    if (divRef.current) {
      infoRef.current.value = '';
      infoRef.current.count = 0;
      divRef.current.innerHTML = '';
      rangeRef.current = null;
      setExpandKey(null);
    }
  };

  // 发送时的操作
  const handleSend = (cb?: (_v: string, _cb: () => void) => void) => {
    if (typeof cb === 'function') {
      cb(infoRef.current.value, onClear);
    }
  };

  // 重写复制的方法
  const handleCopy = (e) => {
    e.preventDefault();
    const sl = window.getSelection();
    if (sl.rangeCount !== 0) {
      let box = document.createElement('div');
      box.appendChild(sl.getRangeAt(0).cloneContents());
      const html = box.innerHTML;
      const isImg = html && html.match(/<img.*>/);
      if (isImg) {
        e.clipboardData.setData('text/html', html);
      } else {
        e.clipboardData.setData('text/plain', html);
      }
      box = null;
    }
  };

  const loopNode = (nodes) => {
    const len = nodes.childNodes.length;
    for (let i = 0; i < len; i += 1) {
      // 最大长度限制
      if (maxLength && maxLength <= infoRef.current.count) {
        break;
      }
      // 节点
      const el: any = nodes.childNodes[i];
      if (el.childNodes.length) {
        loopNode(el);
      } else if ((el.nodeName as string).toLocaleLowerCase() === '#text') {
        // 文本节点直接拼接
        let text = el.nodeValue.replace(/\n/g, '');
        if (text) {
          const maxTextLen = maxLength ? maxLength - infoRef.current.count : text.length;
          text = text.slice(0, maxTextLen);
          // 将文本节点插入到选中节点中
          let textNode = document.createTextNode(text);
          rangeRef.current.insertNode(textNode);
          rangeRef.current.setStartAfter(textNode);
          rangeRef.current.collapse(true);
          textNode = null;
          infoRef.current.count += text.length > maxTextLen ? maxTextLen : text.length;
        }
      } else if ((el.nodeName as string).toLocaleLowerCase() === 'img') {
        // 图片节点
        const cn = el.getAttribute('class');
        insertImg(
          el.getAttribute('src'),
          el.getAttribute('alt'),
          `${cn ? ' ' : ''}inline-block mx-px align-top w-4 h-4`,
        );
        infoRef.current.count += 1;
      }
    }
  };

  const handlePaste = (e) => {
    e.preventDefault();
    let clipboardData =
      e.clipboardData || (e.originalEvent || {}).clipboardData || (window as any).clipboardData;
    let pasteHtml = clipboardData.getData('text/html');
    if (!pasteHtml) {
      const pasteText = clipboardData.getData('text/plain');
      pasteHtml = `<html><body><!--StartFragment--><div data-zone-id="0" data-line-index="0" data-line="true" style="white-space: pre-wrap;">${pasteText}</div><!--EndFragment--></body></html>`;
    }
    // 调用失去焦点的方法是为了获取当前光标对应的选中位置
    handleBlur();
    if (pasteHtml) {
      let paster: any = new DOMParser();
      let doc = paster.parseFromString(pasteHtml, 'text/html');
      insetEmoji({ url: true }, () => {
        getValue();
        loopNode(doc);
      });
      paster = null;
      doc = null;
    }
    clipboardData = null;
  };

  return (
    <>
      <div style={{display: 'flex', alignItems: 'center'}} className={className}>
        {prefix && React.cloneElement(prefix, { setExpandKey })}
        <div
          ref={divRef}
          id={id}
          className={iptClassName}
          contentEditable
          suppressContentEditableWarning
          onBlur={handleBlur}
          onFocus={handleFouces}
          onInput={handleInput}
          // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
          tabIndex={0}
          onBeforeInput={onBeforeInput}
          onCopy={handleCopy}
          onPaste={handlePaste}
        />
        {afterfix && React.cloneElement(afterfix, { handleSend, setExpandKey })}
      </div>
      {expandDom && expandDom.dom ? (
        <div className={expandClassName}>
          {React.cloneElement(expandDom.dom, { insetEmoji })}
        </div>
      ) : null}
    </>
  );
};
export default EmojiInput;

使用

import { useState } from 'react';
import EmojiInput from './EmojiInput';
// 样式这里没给出,如果要看结果需要自己加一下一下
const Img = ({ insetEmoji }) => {
  return (
    <div className="flex">
      {[
        {
          url: 'https://img1.baidu.com/it/u=3223861337,2563245525&fm=253&fmt=auto&app=138&f=JPEG?w=256&h=256',
          alt: '0000',
        },
      ].map((it, i) => (
        <img
          className="w-8 h-8 m-1 cursor-pointer"
          key={i}
          src={it.url}
          alt={it.alt}
          onClick={() => insetEmoji(it)}
        />
      ))}
    </div>
  );
};
const expandKeys = [
  {
    key: 'emo',
    dom: <Img />,
  },
];
const Afterfix = ({ handleSend, onChange, setExpandKey }) => {
  return (
    <div className="flex items-center">
      <Button
        className="mx-2"
        type="primary"
        size="mini"
        onClick={() => {
          setExpandKey((pre) => {
            if (pre !== 'emo') {
              return 'emo';
            }
            return null;
          });
        }}
      >
        展开emoji
      </Button>
      <Button
        type="primary"
        size="mini"
        onClick={() => {
          handleSend(onChange);
        }}
      >
        发送
      </Button>
    </div>
  );
};
export default () => {
  const [value, setValue] = useState('');
  const handleValue = (v, cb) => {
    setValue(v);
    cb();
  };
  return (
      <EmojiInput
        expandKeys={expandKeys}
        afterfix={<Afterfix onChange={handleValue} />}
      />
      <div>值:{value}</div>
  );
};

总结

  • 现在很多输入法都已经自带了很多表情包,图片等,这里只是提供一种思路,熟悉下一些api用法
  • 实现过程中很多api都有兼容问题,这块如果要用时需要注意
  • 关键api window.getSelection相关、e.clipboardData、DOMParser