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

场景

我们的 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();
};
相关推荐
鹏北海18 分钟前
多标签页登录状态同步:一个简单而有效的解决方案
前端·面试·架构
_AaronWong23 分钟前
基于 Vue 3 的屏幕音频捕获实现:从原理到实践
前端·vue.js·音视频开发
孟祥_成都31 分钟前
深入 Nestjs 底层概念(1):依赖注入和面向切面编程 AOP
前端·node.js·nestjs
let_code32 分钟前
CopilotKit-丝滑连接agent和应用-理论篇
前端·agent·ai编程
Apifox1 小时前
Apifox 11 月更新|AI 生成测试用例能力持续升级、JSON Body 自动补全、支持为响应组件添加描述和 Header
前端·后端·测试
木易士心1 小时前
深入剖析:按下 F5 后,浏览器前端究竟发生了什么?
前端·javascript
在掘金801101 小时前
vue3中使用medium-zoom
前端·vue.js
xump1 小时前
如何在DevTools选中调试一个实时交互才能显示的元素样式
前端·javascript·css
折翅嘀皇虫1 小时前
fastdds.type_propagation 详解
java·服务器·前端
Front_Yue1 小时前
深入探究跨域请求及其解决方案
前端·javascript