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

场景

我们的 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();
};
相关推荐
待╮續5 小时前
Java开发 - 缓存
前端·bootstrap·html
webKity5 小时前
React 的基本概念介绍
javascript·react.js
Scarlett5 小时前
初识cocos,实现《FlappyBird》h5游戏
前端·cocos creator
古夕5 小时前
Vue 3 复杂表单父子组件双向绑定的最佳实践
前端·javascript·vue.js
烛阴5 小时前
TypeScript 进阶必修课:解锁强大的内置工具类型(一)
前端·javascript·typescript
anyup5 小时前
太全面啦!总结篇!99% 开发者可能都会遇到的 uView Pro 组件库问题
前端·vue.js·uni-app
૮・ﻌ・5 小时前
CSS基础学习第二天
前端·css·学习·emmet语法
Zayn5 小时前
前端路径别名跳转和提示失效?一文搞懂解决方案
前端·javascript·visual studio code