场景

我们的 AI 应用要做类似扣子空间的同款模板输入功能,但是这个组件肯定不能用文本域(因为里面不能再加自定义输入框),怎么办呢?看了一下扣子的实现方式,是用<div contentEditable=true></div>
+ span标签实现的,原来如此。
实现
实现效果:
首先要和后端约定好输入模板接口的返回格式,我们是这样实现的:
json
{
"code": 200,
"message": "success",
"data": {
"template_mode": "请帮我搜索__doctor_name__,他的科室是__department__,他所在的医院是__hospital__,我还想知道关于__product_info__的信息以及和客户有没有关联。",
"keyword": [
"doctor_name",
"department",
"hospital",
"product_info"
]
}
}
html部分这样写就行,注意设置contentEditable
属性
jsx
<div
id="editor"
ref={editorRef}
contentEditable
className="template-editor whitespace-pre-line focus-visible:outline-0"
></div>
css部分
less
.template-input {
display: inline-block;
min-width: 5px;
border: 0;
border-radius: 4px;
margin: 0 2px;
padding: 0 6px;
color: #969fffb3;
background-color: rgba(181, 191, 255, 23%);
&:focus-within {
background-color: rgba(181, 191, 255, 23%);
}
&:hover {
background-color: rgba(181, 191, 255, 23%);
}
// 当内容为空时,使用 data-original 作为占位符显示
&:empty::before {
content: attr(data-original);
color: #969fffb3;
}
}
.agent-input-area {
border: 0;
border-radius: 0;
padding: 0;
&:focus-within {
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0%);
}
}
#editor:focus-visible {
outline: 0;
}
js部分
主要分为三个功能函数:
renderEditor()
函数负责从后端获取约定好的输入模板,并在editor
元素上渲染出来saveTextContent()
是个防抖函数,通过document.createTreeWalker
遍历editor
元素内的子元素内容,并把内容存储起来handleKeydown()
处理每次keydown
的逻辑,主要是防止当用户删除完子输入框的最后一个元素时会把整个子输入框也删除掉的情况,这种时候需要展示初始值,也就是placeholder的效果
renderEditor()
函数
js
const renderEditor = async () => {
const res = await services.fetchPromptTemplate();
const keywords = res.keyword;
const templateStr = res.template_mode.trim();
const fragments = templateStr.split('__');
const templateObj = {
parts: [],
};
fragments.forEach((fragment) => {
if (keywords.includes(fragment)) {
templateObj.parts.push({
type: 'input',
id: fragment,
value: fragment,
});
} else {
templateObj.parts.push({
type: 'text',
content: fragment,
});
}
});
const editor = editorRef.current;
editor.innerHTML = '';
templateObj.parts?.forEach((part) => {
if (part.type === 'text') {
const textNode = document.createTextNode(part.content);
editor.appendChild(textNode);
} else if (part.type === 'input') {
const span = document.createElement('span');
span.className = 'template-input cursor-text';
span.dataset.id = part.id;
// 使用空内容 + CSS :empty::before 展示占位符
span.dataset.original = part.value;
span.textContent = '';
// 让 span 始终可编辑,这样 beforeinput 事件才能正常触发
span.contentEditable = true;
span.addEventListener('click', (e) => {
e.stopPropagation();
span.focus();
});
// 为 span 添加一个标识,方便在父级事件中识别
span.dataset.isTemplateInput = 'true';
editor.appendChild(span);
}
});
};
saveTextContent()
函数
js
const saveTextContent = debounce(() => {
const editor = editorRef.current;
const walker = document.createTreeWalker(editor);
let currentNode;
let completedText = '';
while ((currentNode = walker.nextNode())) {
if (currentNode.classList?.contains('template-input')) {
const key = currentNode.dataset.id;
const val = (currentNode.textContent || '').trim();
setParamsObj((prev) => ({ ...prev, [key]: val }));
continue;
}
completedText += currentNode.textContent;
}
setInputValue(completedText);
}, 500);
handleKeydown()
函数
js
const handleKeydown = (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
// 检查当前光标是否在 template-input 内
const selection = window.getSelection?.();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const targetSpan =
range.startContainer.parentElement?.closest('.template-input');
if (targetSpan) {
const currentText = targetSpan.textContent || '';
if (currentText.length <= 1) {
e.preventDefault();
targetSpan.textContent = '';
// 将光标保持在 span 内部
const r = document.createRange();
r.setStart(targetSpan, 0);
r.collapse(true);
selection.removeAllRanges();
selection.addRange(r);
saveTextContent();
}
}
}
}
saveTextContent();
};