写一个带联想词的输入框,看这篇文章就够了

PS:点赞,评论,收藏,分享 防止迷路

一 功能描述

使用 contenteditable 的 div文本输入框。

  1. 用户在可编辑div中输入#号时触发关键词联想

  2. 联想列表根据输入内容过滤显示

  3. 支持鼠标点击和键盘导航选择

  4. 插入的标签不可编辑且带有特殊样式

  5. 光标定位在标签之后

二 效果展示

三 代码

ini 复制代码
<!DOCTYPE html>
<html>
  <head>
    <style>
      .editor {
        width: 500px;
        height: 200px;
        border: 1px solid #ccc;
        padding: 10px;
        font-family: Arial;
        position: relative;
        outline: none;
      }

      .suggestions {
        position: absolute;
        background: white;
        border: 1px solid #ddd;
        max-height: 200px;
        overflow-y: auto;
        display: none;
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        z-index: 1000;
      }

      .suggestion-item {
        padding: 8px;
        cursor: pointer;
        transition: background 0.2s;
      }

      .suggestion-item:hover,
      .suggestion-item.selected {
        background: #f0f0f0;
      }

      .editor b {
        font-weight: bold;
        color: #007bff;
        display: inline-block;
      }
    </style>
  </head>
  <body>
    <div id="editor" class="editor" contenteditable="true"></div>
    <div id="suggestions" class="suggestions"></div>

    <script>
      const keywords = [
        "前端开发",
        "JavaScript",
        "CSS技巧",
        "Vue框架",
        "React教程",
        "Node.js",
        "Webpack配置",
      ];
      let currentSelection = -1;
      let lastWord = "";
      let lastValidRange = null;

      const editor = document.getElementById("editor");
      const suggestions = document.getElementById("suggestions");

      // 输入事件监听
      editor.addEventListener("input", handleInput);
      editor.addEventListener("keydown", handleKeyDown);

      // 点击建议项处理(事件委托)
      suggestions.addEventListener("mousedown", (e) => {
        e.preventDefault(); // 阻止默认焦点变化
        const item = e.target.closest(".suggestion-item");
        if (item) {
          insertSuggestion(item.dataset.value);
        }
      });

      // 输入处理
      function handleInput() {
        const selection = window.getSelection();
        if (!selection.rangeCount) return;

        const range = selection.getRangeAt(0);
        if (!editor.contains(range.commonAncestorContainer)) return;

        // 保存有效选区
        lastValidRange = range.cloneRange();

        const text = range.startContainer.textContent || "";
        const pos = range.startOffset;

        // 匹配#号开头的内容
        const match = text.slice(0, pos).match(/#([^#\s]*)$/);

        if (match) {
          lastWord = match[1];
          showSuggestions(match[1]);
          positionSuggestions(range);
        } else {
          hideSuggestions();
        }
      }

      // 显示建议列表
      function showSuggestions(input) {
        const filtered = keywords.filter((k) =>
          k.toLowerCase().includes(input.toLowerCase())
        );

        suggestions.innerHTML = filtered
          .map(
            (k, i) => `
                    <div class="suggestion-item ${i === 0 ? "selected" : ""}" 
                         data-value="${k}">
                        ${k.replace(
                          new RegExp(`(${input})`, "gi"),
                          "<b>$1</b>"
                        )}
                    </div>
                `
          )
          .join("");

        currentSelection = filtered.length > 0 ? 0 : -1;
        suggestions.style.display = filtered.length ? "block" : "none";
      }

      // 定位建议框
      function positionSuggestions(range) {
        const rect = range.getBoundingClientRect();
        suggestions.style.top = `${rect.top + window.scrollY + 24}px`;
        suggestions.style.left = `${rect.left + window.scrollX}px`;
      }

      // 插入建议词
      function insertSuggestion(text) {
        // 恢复焦点和选区
        editor.focus();
        const selection = window.getSelection();

        if (lastValidRange) {
          selection.removeAllRanges();
          selection.addRange(lastValidRange);
        }

        requestAnimationFrame(() => {
          const range = selection.getRangeAt(0);
          const node = range.startContainer;
          const offset = range.startOffset;

          // 计算替换范围
          const fullText = node.textContent || "";
          const startPos = fullText.lastIndexOf("#", offset - 1);

          if (startPos === -1) return;

          // 创建不可编辑标签
          const tag = document.createElement("b");
          tag.contentEditable = "false";
          tag.textContent = `#${text}`;

          // 创建零宽空格用于光标定位
          const space = document.createTextNode("\u200B");

          // 分割原始节点
          const beforeText = fullText.slice(0, startPos);
          const afterText = fullText.slice(offset);

          // 构建新DOM结构
          const newNodes = [
            document.createTextNode(beforeText),
            tag,
            document.createTextNode(afterText),
            space,
          ];

          // 替换DOM
          if (node.nodeType === Node.TEXT_NODE) {
            const parent = node.parentNode;
            newNodes.forEach((n) => parent.insertBefore(n, node));
            parent.removeChild(node);
          }

          // 定位到零宽空格位置
          const newRange = document.createRange();
          newRange.setStart(space, 0);
          newRange.collapse(true);

          selection.removeAllRanges();
          selection.addRange(newRange);

          // 自动滚动到可见区域
          tag.scrollIntoView({ block: "nearest" });
          hideSuggestions();
        });
      }

      // 键盘处理
      function handleKeyDown(e) {
        if (suggestions.style.display === "none") return;

        const items = suggestions.children;

        switch (e.key) {
          case "ArrowUp":
            e.preventDefault();
            updateSelection(currentSelection - 1);
            break;

          case "ArrowDown":
            e.preventDefault();
            updateSelection(currentSelection + 1);
            break;

          case "Enter":
          case " ":
            e.preventDefault();
            if (currentSelection > -1) {
              insertSuggestion(items[currentSelection].dataset.value);
            }
            break;
        }
      }

      // 更新选中项
      function updateSelection(newIndex) {
        const items = suggestions.children;
        if (!items.length) return;

        newIndex = Math.max(0, Math.min(items.length - 1, newIndex));

        items[currentSelection]?.classList.remove("selected");
        currentSelection = newIndex;
        items[currentSelection].classList.add("selected");

        // 滚动到可见区域
        items[currentSelection].scrollIntoView({
          block: "nearest",
        });
      }

      function hideSuggestions() {
        suggestions.style.display = "none";
        currentSelection = -1;
      }

      // 点击外部隐藏建议框
      document.addEventListener("click", (e) => {
        if (!editor.contains(e.target) && !suggestions.contains(e.target)) {
          hideSuggestions();
        }
      });
      // 新增:处理退格键删除标签
      editor.addEventListener("keydown", (e) => {
        if (e.key === "Backspace") {
          const selection = window.getSelection();
          const range = selection.getRangeAt(0);

          // 如果光标在标签后,向前跳过一个不可编辑节点
          if (
            range.startContainer ===
            document.querySelector('b[contenteditable="false"] + text')
          ) {
            const prev = range.startContainer.previousSibling;
            if (prev?.contentEditable === "false") {
              const newRange = document.createRange();
              newRange.setStart(
                prev.previousSibling,
                prev.previousSibling.length
              );
              newRange.collapse(true);
              selection.removeAllRanges();
              selection.addRange(newRange);
              e.preventDefault();
            }
          }
        }
      });
    </script>
  </body>
</html>

四 关键技术讲解

  1. 要实现插入联想词 不能用 常规的 input 标签,必须要使用 contenteditable 。contenteditable 是一个枚举属性,表示元素是否可被用户编辑
  2. window.getSelection 返回 返回一个 Selection 对象,表示用户当前选择的文本范围或光标的当前位置。
  • 锚点 (anchor)

    锚指的是一个选区的起始点(不同于 HTML 中的锚点链接,译者注)。当我们使用鼠标框选一个区域的时候,锚点就是我们鼠标按下瞬间的那个点。在用户拖动鼠标时,锚点是不会变的。

  • 焦点 (focus)

    选区的焦点是该选区的终点,当你用鼠标框选一个选区的时候,焦点是你的鼠标松开瞬间所记录的那个点。随着用户拖动鼠标,焦点的位置会随着改变。

  • 范围 (range)

    范围指的是文档中连续的一部分。一个范围包括整个节点,也可以包含节点的一部分,例如文本节点的一部分。用户通常下只能选择一个范围,但是有的时候用户也有可能选择多个范围(例如当用户按下 Control 按键并框选多个区域时,Chrome 中禁止了这个操作,译者注)。"范围"会被作为 Range 对象返回。Range 对象也能通过 DOM 创建、增加、删减

  1. 插入的节点需要设置不可编辑

PS:创作不易 学会了记得,点赞,评论,收藏,分享

相关推荐
喝拿铁写前端5 小时前
前端与 AI 结合的 10 个可能路径图谱
前端·人工智能
codingandsleeping5 小时前
浏览器的缓存机制
前端·后端
-代号95276 小时前
【JavaScript】十二、定时器
开发语言·javascript·ecmascript
灵感__idea7 小时前
JavaScript高级程序设计(第5版):扎实的基本功是唯一捷径
前端·javascript·程序员
摇滚侠7 小时前
Vue3 其它API toRow和markRow
前端·javascript
難釋懷7 小时前
JavaScript基础-history 对象
开发语言·前端·javascript
beibeibeiooo7 小时前
【CSS3】04-标准流 + 浮动 + flex布局
前端·html·css3
拉不动的猪7 小时前
刷刷题47(react常规面试题2)
前端·javascript·面试
浪遏7 小时前
场景题:大文件上传 ?| 过总字节一面😱
前端·javascript·面试
计算机毕设定制辅导-无忧学长7 小时前
HTML 与 JavaScript 交互:学习进程中的新跨越(一)
javascript·html·交互