需求整理
- 支持输入文字、图片
- 支持复制、粘贴功能
- 支持长度限制、发送功能
- 支持添加前缀、后缀
实现思路整理
- 使用
div标签作为输入控件(原生的input是无法实现输入图片并展示在input内)- 添加
contentEditable则div标签可编辑// suppressContentEditableWarning 去除警告 <div contentEditable suppressContentEditableWarning /> - 设置
tabIndex属性值为0则div标签可聚焦<div tabIndex={0} /> - 设置
id(唯一标识),ref(保存当前div节点的内容),以及设置样式(看起来更像一个输入框)
- 添加
- 要支持输入图片以及长度限制,那就需要两个变量分别去保存
输入框的value和字符length- 使用ref保存上面的值
import React from 'react'; const infoRef = React.useRef({ value: '', count: 0 })- 要控制字符长度,需要在合适的时机阻止输入框默认的输入事件
// maxLength 限制输入框可输入的最大字符长度 const onBeforeInput = (e) => { if (maxLength && maxLength <= infoRef.current.count) { e.preventDefault(); // 阻止默认事件 } }; // 通过这个事件控制达到最大长度时禁止输入 <div onBeforeInput={onBeforeInput} /> - 插入图片功能,这里有四个核心问题要梳理清楚
- 插入图片时,
输入框是否聚焦? - 是否
选中了输入框中的内容? - 是否
选中了输入框外的内容? - 插入到什么位置(也就是
光标如何定位)? - 具体实现
// 首先要解决的是光标定位问题,这里可以使用 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; } };到这里图片插入的功能基本实现,接下来还要处理输入事件
// 输入事件只需要重新获取下value、和length即可,本身就支持文字输入的 const handleInput = (e) => { try { if (e && e.nativeEvent) { getValue(); } } catch (error) { console.error(error); } };
- 重写复制方法,关键api
clipboardData
// 重写复制的方法
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