前言
这是一个使用 contentEditable 为基础的输入框,支持识别关键字转换为标签以及快捷提示输入。
待优化
- 目前 Chrome、Edge 浏览器都是支持的,其他浏览器可能会有兼容问题;
- 快捷输入只支持 空格 选中,后续考虑加入点击选中;
需求
-
输入框,允许输入任意文本,单行展示,过滤空格;
-
当用户输入关键字后,需要将关键字展示为特殊样式;
比如: 用户输入 123keyword456 时,keyword作为识别的关键字,处理后如下展示
- 支持复制粘贴,注意粘贴时去除格式;
- 当用户输入 @ 符号时,弹出提示,按下空格自动填充关键字;
完整交互效果
知识点
Selection 对象
window.selection用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。collapseToEnd取消当前选区,并把光标定位在原选区的最末尾处,如果此时光标所处的位置是可编辑的,且它获得了焦点,则光标会在原地闪烁。
Range 对象
-
selection.getRangeAt(0)返回一个包含当前选区内容的区域对象 -
range.setStart(dom, start)设置选区开始位置 -
range.setEnd(dom, end)设置选区结束位置 -
range.deleteContents()删除选区内容 -
range.insertNode(node)在选区起始位置插入节点 -
range.createContextualFragment(htmlStr)可以将html文本转换为html节点
clipboardData 剪贴板
-
(e.originalEvent || e)?.clipboardData从事件对象获取剪贴板对象 -
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|(?: )|\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;
}
}
}
}