Skip to content

可编辑标签高亮

效果演示

可编辑标签高亮

在下面的文本框中输入内容,支持 #, @, 和 * 开头的标签。 "#官方", "#推荐", "#热点" 为官方标签。

Vue3 源码

vue
<template>
  <div class="editable-tags">
    <h3>可编辑标签高亮</h3>
    <p>
      在下面的文本框中输入内容,支持
      <code>#</code>, <code>@</code>, 和 <code>*</code> 开头的标签。 "#官方",
      "#推荐", "#热点" 为官方标签。
    </p>
    <div
      ref="editableDiv"
      class="editable"
      contenteditable="true"
      @input="handleInput"
      @keydown="handleKeyDown"
      @compositionstart="handleCompositionStart"
      @compositionend="handleCompositionEnd"
    ></div>
    <button @click="getEditableData">获取数据</button>
    <button @click="setEditableData">回显数据</button>
  </div>
</template>
<script setup>
import { ref, nextTick, onMounted } from "vue";

// 可编辑区域的引用
const editableDiv = ref(null);

// 判断是否为官方标签
const isOfficialTag = (tag) => {
  const officialTags = ["#官方", "#推荐", "#热点"]; // 官方标签定义
  return officialTags.includes(tag);
};

// 高亮标签
const highlightTags = (text) => {
  const parts = text.split(/(\s+)/); // 保留空格
  return parts
    .map((part) => {
      if (part.startsWith("#")) {
        if (isOfficialTag(part)) {
          return `<span class="tag prefix-hash-official">${part}</span>`;
        } else {
          return `<span class="tag prefix-hash-custom">${part}</span>`;
        }
      } else if (part.startsWith("@")) {
        return `<span class="tag prefix-at">${part}</span>`;
      } else if (part.startsWith("*")) {
        return `<span class="tag prefix-star">${part}</span>`;
      } else {
        return part.replace(/\n/g, ""); // 保留换行
      }
    })
    .join("");
};

// 保存光标位置
const saveCaretPosition = (context) => {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return null;

  const range = selection.getRangeAt(0);
  const preCaretRange = range.cloneRange();
  preCaretRange.selectNodeContents(context);
  preCaretRange.setEnd(range.startContainer, range.startOffset);
  return preCaretRange.toString().length; // 返回光标位置
};

// 恢复光标位置
const restoreCaretPosition = (context, position) => {
  const selection = window.getSelection();
  const range = document.createRange();

  let charCount = 0;

  function traverseNodes(currentNode) {
    if (currentNode.nodeType === Node.TEXT_NODE) {
      const nextCharCount = charCount + currentNode.textContent.length;
      if (position <= nextCharCount) {
        range.setStart(currentNode, position - charCount);
        range.collapse(true);
        return true;
      }
      charCount = nextCharCount;
    } else if (currentNode.nodeType === Node.ELEMENT_NODE) {
      for (let i = 0; i < currentNode.childNodes.length; i++) {
        if (traverseNodes(currentNode.childNodes[i])) return true;
      }
    }
    return false;
  }

  traverseNodes(context);
  selection.removeAllRanges();
  selection.addRange(range);
};

// 获取编辑区域中的数据
const getEditableData = () => {
  if (editableDiv.value) {
    const text = editableDiv.value.innerText; // 提取纯文本
    alert(`获取的数据:\n${text}`);
  }
};

// 设置编辑区域的数据并回显高亮
const setEditableData = () => {
  const sampleData = "#官方 @演示 *自定义 #热点 #测试 ";
  if (editableDiv.value) {
    const highlightedHTML = highlightTags(sampleData); // 高亮处理
    editableDiv.value.innerHTML = highlightedHTML;
    nextTick(() => {
      restoreCaretPosition(
        editableDiv.value,
        editableDiv.value.innerText.length
      ); // 将光标移到末尾
    });
  }
};

// 检测是否清空并恢复焦点
const ensureCaretPosition = () => {
  if (editableDiv.value && editableDiv.value.innerHTML.trim() === "") {
    editableDiv.value.innerHTML = ""; // 清空内容
    nextTick(() => {
      const range = document.createRange();
      range.selectNodeContents(editableDiv.value);
      range.collapse(true);
      const selection = window.getSelection();
      selection.removeAllRanges();
      selection.addRange(range);
    });
  }
};

let isComposing = false; // 标记是否正在拼音输入状态

const handleCompositionStart = () => {
  isComposing = true; // 拼音联想开始
  // console.log('拼音联想开始');
};

const handleCompositionEnd = (event) => {
  isComposing = false; // 拼音联想结束
  // 在拼音输入完成时处理最终输入
  console.log("Composition ended:", event.target.innerHTML);
  handleInput();
};

// 监听用户输入事件
const handleInput = () => {
  if (!editableDiv.value) {
    return;
  }

  if (isComposing) {
    // 拼音输入联想状态中,不处理输入
    return;
  }

  const caretPosition = saveCaretPosition(editableDiv.value); // 保存光标位置
  const text = editableDiv.value.innerText;
  editableDiv.value.innerHTML = highlightTags(text); // 更新内容
  nextTick(() => {
    restoreCaretPosition(editableDiv.value, caretPosition); // 恢复光标位置
  });
};

// 监听删除键事件
const handleKeyDown = (event) => {
  if (event.key === "Backspace") {
    setTimeout(() => {
      ensureCaretPosition(); // 确保内容清空后光标仍存在
    });
  }
};

onMounted(() => {
  setEditableData();
});
</script>

<style lang="scss">
.editable-tags {
  button {
    padding: 2px 8px;
    border-radius: 5px;
    background-color: #eee;
  }
  button + button {
    margin-left: 8px;
  }
  .editable {
    width: 100%;
    min-height: 100px;
    border: 1px solid #ccc;
    padding: 10px;
    border-radius: 5px;
    font-size: 16px;
    line-height: 1.5;
    outline: none;
    white-space: pre-wrap;
    margin-bottom: 12px;
  }
  .tag {
    display: inline-block;
    margin: 2px;
    border-radius: 3px;
    font-weight: bold;
  }
  .prefix-hash-official {
    color: #108ee9;
  }
  .prefix-hash-custom {
    color: #f59a23;
  }
  .prefix-at {
    color: #d9001b;
  }
  .prefix-star {
    color: #4b7902;
  }
}
</style>

html 源码

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>可编辑标签高亮</title>
    <style>
      body {
        font-family: Arial, sans-serif;
        margin: 20px;
      }
      .editable {
        width: 100%;
        min-height: 100px;
        border: 1px solid #ccc;
        padding: 10px;
        border-radius: 5px;
        font-size: 16px;
        line-height: 1.5;
        outline: none;
        white-space: pre-wrap;
      }
      .tag {
        display: inline-block;
        margin: 2px;
        border-radius: 3px;
        font-weight: bold;
      }
      .prefix-hash-official {
        color: #108ee9;
      }
      .prefix-hash-custom {
        color: #f59a23;
      }
      .prefix-at {
        color: #d9001b;
      }
      .prefix-star {
        color: #4b7902;
      }
    </style>
  </head>
  <body>
    <h1>可编辑标签高亮</h1>
    <p>
      在下面的文本框中输入内容,支持 <code>#</code>, <code>@</code>, 和
      <code>*</code> 开头的标签。 "#官方", "#推荐", "#热点" 为官方标签
    </p>
    <div id="editable" class="editable" contenteditable="true"></div>
    <button id="get-data-btn">获取数据</button>
    <button id="set-data-btn">回显数据</button>

    <script>
      const editableDiv = document.getElementById("editable");
      const getDataBtn = document.getElementById("get-data-btn");
      const setDataBtn = document.getElementById("set-data-btn");

      // 判断是否为官方标签
      function isOfficialTag(tag) {
        const officialTags = ["#官方", "#推荐", "#热点"]; // 官方标签定义
        return officialTags.includes(tag);
      }

      // 高亮标签
      function highlightTags(text) {
        const parts = text.split(/(\s+)/); // 保留空格
        return parts
          .map((part) => {
            if (part.startsWith("#")) {
              if (isOfficialTag(part)) {
                return `<span class="tag prefix-hash-official">${part}</span>`;
              } else {
                return `<span class="tag prefix-hash-custom">${part}</span>`;
              }
            } else if (part.startsWith("@")) {
              return `<span class="tag prefix-at">${part}</span>`;
            } else if (part.startsWith("*")) {
              return `<span class="tag prefix-star">${part}</span>`;
            } else {
              return part.replace(/\n/g, "<br>"); // 保留换行
            }
          })
          .join("");
      }

      // 获取光标位置
      function saveCaretPosition(context) {
        const selection = window.getSelection();
        if (selection.rangeCount === 0) return null;

        const range = selection.getRangeAt(0);
        const preCaretRange = range.cloneRange();
        preCaretRange.selectNodeContents(context);
        preCaretRange.setEnd(range.startContainer, range.startOffset);
        return preCaretRange.toString().length; // 返回光标位置
      }

      // 恢复光标位置
      function restoreCaretPosition(context, position) {
        const selection = window.getSelection();
        const range = document.createRange();

        let charCount = 0;
        let node;

        function traverseNodes(currentNode) {
          if (currentNode.nodeType === Node.TEXT_NODE) {
            const nextCharCount = charCount + currentNode.textContent.length;
            if (position <= nextCharCount) {
              range.setStart(currentNode, position - charCount);
              range.collapse(true);
              return true;
            }
            charCount = nextCharCount;
          } else if (currentNode.nodeType === Node.ELEMENT_NODE) {
            for (let i = 0; i < currentNode.childNodes.length; i++) {
              if (traverseNodes(currentNode.childNodes[i])) return true;
            }
          }
          return false;
        }

        traverseNodes(context);
        selection.removeAllRanges();
        selection.addRange(range);
      }

      // 获取编辑区域中的数据
      function getEditableData() {
        const text = editableDiv.innerText; // 提取纯文本
        console.log("获取的纯文本数据:", text);
        return text;
      }

      // 设置编辑区域的数据并回显高亮
      function setEditableData(data) {
        const highlightedHTML = highlightTags(data); // 高亮处理
        editableDiv.innerHTML = highlightedHTML;
      }

      // 监听用户输入
      editableDiv.addEventListener("input", () => {
        const caretPosition = saveCaretPosition(editableDiv); // 保存光标位置
        const text = editableDiv.innerText;
        editableDiv.innerHTML = highlightTags(text); // 更新内容
        restoreCaretPosition(editableDiv, caretPosition); // 恢复光标位置
      });

      // 按钮事件
      getDataBtn.addEventListener("click", () => {
        const data = getEditableData();
        alert(`获取的数据:\n${data}`);
      });

      setDataBtn.addEventListener("click", () => {
        const sampleData = "#官方 @演示 *自定义 #热点 #测试";
        setEditableData(sampleData);
      });

      // 初始化
      editableDiv.innerHTML = highlightTags("");
    </script>
  </body>
</html>