扣子同款半固定输入模板的简单解决方案

场景

我们的 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部分

主要分为三个功能函数:

  1. renderEditor()函数负责从后端获取约定好的输入模板,并在editor元素上渲染出来
  2. saveTextContent()是个防抖函数,通过document.createTreeWalker遍历editor元素内的子元素内容,并把内容存储起来
  3. 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();
};
相关推荐
库拉镜像AI牛牛32 分钟前
短剧内容自动化生产:知漫剧工作室落地教程
大数据·服务器·前端·人工智能·语音识别
律宏阔34 分钟前
Flutter 列表图片内存优化:CachedNetworkImage 与 ClipRRect 的取舍
前端·flutter
行者全栈架构师35 分钟前
【鸿蒙心迹】相册文搜图工程化落地:HarmonyOS 7.0 端侧 AI 检索全链路实战与 5 个踩坑解法
前端
律宏阔44 分钟前
Dart 3 Record:解决 Future.wait 异构返回值的类型问题
前端·flutter
Seraphina361 小时前
DVWA(XSS Reflected/Stored High,CSRF Low)
前端·xss·csrf
默_笙2 小时前
🍔 中间件不只是打日志:四个钩子、一次短路,和自带的工具
前端·javascript
程序员老赵3 小时前
Docker 部署 Dolibarr:轻松搭建开源 ERP/CRM 平台
运维·前端·后端
星禾元亨3 小时前
同一件事有三份资料,AI 该信哪一份?冲突判定、优先级链与处置流程
前端·人工智能
Dovis(誓平步青云)3 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
穆梓兰煊3 小时前
TS5.7 vs 裸JS:AI应用少3坑
前端