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

场景

我们的 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();
};
相关推荐
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅5 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊5 小时前
jwt介绍
前端
爱敲代码的小鱼5 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax