当前位置: 首页
前端开发
Vue实现输入框@人功能的完整方法与代码详解

Vue实现输入框@人功能的完整方法与代码详解

热心网友 时间:2026-06-13
转载

基于tiptapmention插件实现富文本编辑器@人功能,被@人名显示蓝色、普通文本黑色。导出HTML时,通过结构中的自定义属性标记提及对象,可直接提取,无需正则匹配。

在富文本编辑器中实现 @人名 的输入功能,核心思路非常清晰——借助 Tiptap 的 Mention 插件即可轻松完成。被 @ 的用户名以蓝色高亮显示,而普通文本保持默认黑色,视觉上形成鲜明对比,让用户一目了然。

在 Vue 中实现输入框@人功能

这里有一个关键前提:整个组件最终导出的是 HTML 格式的富文本,而非纯文本。这意味着后续处理被提及对象时,可以直接从富文本 DOM 结构中解析提取,无需依赖正则表达式进行人名匹配。

具体实现方面,核心代码如下所示——

<script lang="ts" setup>
import { autoPlacement, autoUpdate, offset, useFloating } from "@floating-ui/vue";
import Document from "@tiptap/extension-document";
import Mention from "@tiptap/extension-mention";
import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";
import type { SuggestionProps } from "@tiptap/suggestion";
import { EditorContent, useEditor, type JSONContent } from "@tiptap/vue-3";
import { useElementVisibility } from "@vueuse/core";
import { useDebounceFn } from "@vueuse/core";
import type { ScrollbarDirection } from "element-plus";
import { omit } from "es-toolkit/object";
import type { MaybePromise, Suggestion } from "./suggestion";const props = defineProps<{
  /** 获取建议列表的函数 */
  fetchSuggestions: (query: string) => MaybePromise<Suggestion[]>;
}>();/** 编辑器 HTML 内容的双向绑定 */
const modelValue = defineModel();const emit = defineEmits<{
  infiniteScroll: [query: string | undefined, items: Suggestion[] | undefined];
  updateMentions: [list: Suggestion[]];
}>();/** 监听外部内容变化,同步到编辑器 */
watch(modelValue, (value) => {
  if (value === editor.value?.getHTML()) return;
  editor.value?.commands.setContent(value || "", { emitUpdate: false });
});/** 当前 TipTap 建议对象,包含触发提及的位置等信息 */
const suggestion = reactive<Partial<SuggestionProps<Suggestion>>>({});/**
 * 计算提及触发的参考元素
 * 用于定位建议弹窗
 */
const reference = computed(() => {
  const { decorationNode } = suggestion;
  if (!(decorationNode instanceof HTMLElement)) return;
  return decorationNode;
});/** 检测参考元素是否在视口中可见 */
const isMentionVisible = useElementVisibility(reference);
const floating = useTemplateRef("floating-element");/**
 * 计算是否应该显示建议弹窗
 */
const isShowPopper = computed(() => {
  const { items } = suggestion;
  return items?.length && reference.value && isMentionVisible.value;
});/** 当前选中的建议索引 */
const selectedIndex = ref(0);/**
 * 更新选中的建议索引并滚动到对应元素
 *
 * @param index - 新的选中索引
 */
const changeSelectedIndex = (index: number) => {
  selectedIndex.value = index;
  const list = floating.value?.querySelectorAll(`[type="button"]`);
  if (!list) return;
  list[index]?.scrollIntoView({
    block: "nearest",
    beha vior: "smooth",
  });
};const { floatingStyles } = useFloating(reference, floating, {
  whileElementsMounted: autoUpdate,
  middleware: [
    offset(4),
    autoPlacement({
      allowedPlacements: ["bottom-start", "top-start", "bottom-end", "top-end"],
      padding: 4,
    }),
  ],
});/**
 * 设置自动更新并重置选中索引
 */
const startUpdatePosition = (value: SuggestionProps) => {
  Object.assign(suggestion, omit(value, ["editor"]));
  changeSelectedIndex(0);
};const mentionExtension = Mention.configure({
  deleteTriggerWithBackspace: true,
  suggestion: {
    allowedPrefixes: null,
    /**
     * 获取建议项列表
     * @returns 建议项列表
     */
    items: ({ query }) => {
      return props.fetchSuggestions(query);
    },
    /**
     * 自定义建议列表渲染器
     * 处理建议列表的显示、隐藏和键盘导航
     */
    render: () => {
      return {
        /** 开始显示建议时触发 */
        onStart(props) {
          startUpdatePosition(props);
        },
        /** 建议更新时触发 */
        onUpdate(props) {
          startUpdatePosition(props);
        },
        /** 键盘事件处理 */
        onKeyDown({ event }) {
          // ESC 键:关闭建议
          if (event.key === "Escape") {
            suggestion.items = undefined;
            return true;
          }          const items = suggestion.items || [];
          const length = items.length;
          const current = selectedIndex.value;          // 上箭头:选择上一项
          if (event.key === "ArrowUp") {
            changeSelectedIndex((current + length - 1) % length);
            return true;
          }          // 下箭头:选择下一项
          if (event.key === "ArrowDown") {
            changeSelectedIndex((current + 1) % length);
            return true;
          }          // 回车:选择当前项
          if (event.key === "Enter") {
            const item = items[current];
            if (item) handleClickItem(item);
            return true;
          }
          return false;
        },
        /** 退出建议状态时触发 */
        onExit() {
          suggestion.items = undefined;
        },
      };
    },
  },
});/**
 * 递归查找文档中的所有提及节点
 *
 * 该函数遍历 TipTap JSON 文档树,提取所有类型为 "mention" 的节点,
 * 并将其 id 和 label 属性收集到结果数组中。支持单个节点或节点数组作为输入。
 *
 * @param doc - TipTap JSON 文档对象或节点数组,undefined 时函数直接返回
 * @param result - 用于收集提及数据的结果数组,函数会将找到的提及节点追加到此数组
 *
 * @example
 * ```typescript
 * const mentions: Suggestion[] = [];
 * const json = editor.getJSON();
 * findMention(json, mentions);
 * console.log(`找到 ${mentions.length} 个提及`);
 * ```
 */
const findMention = (doc: JSONContent | JSONContent[] | undefined, result: Suggestion[]): void => {
  if (!doc) return;
  if (Array.isArray(doc)) {
    doc.forEach((node) => findMention(node, result));
    return;
  }
  const { type, content, attrs } = doc;
  if (type === "mention") {
    if (!attrs) return;
    const { id, label } = attrs;
    result.push({ id, label });
    return;
  }
  if (content) {
    content.forEach((node) => findMention(node, result));
    return;
  }
};/**
 * 创建 TipTap 编辑器实例
 * 配置基础的文档结构、段落、文本和提及功能
 */
const editor = useEditor({
  extensions: [Document, Paragraph, Text, mentionExtension],
  content: modelValue.value || "",
  onUpdate: useDebounceFn(() => {
    if (!editor.value) return;
    modelValue.value = editor.value.getHTML() || "";
    // 提取所有提及节点并更新提及列表
    const list: Suggestion[] = [];
    findMention(editor.value.getJSON(), list);
    emit("updateMentions", list);
  }, 200),
});/**
 * 处理编辑器容器的点击事件
 * 在编辑器未聚焦时点击容器将聚焦到编辑器末尾
 *
 * @param event - 鼠标点击事件
 */
const handleClickContainer = (event: MouseEvent) => {
  if (editor.value?.isFocused) return;
  const { target } = event;
  if (!(target instanceof Element)) return;
  if (target.closest(".tiptap")) return;
  event.preventDefault();
  editor.value?.commands.focus("end");
};/**
 * 处理建议项的点击事件
 * 执行 TipTap 的提及命令来插入选中的建议项
 *
 * @param item - 被选中的建议项
 */
const handleClickItem = (item: Suggestion) => {
  const { command } = suggestion;
  if (command) command(item);
};/**
 * 处理无限滚动事件
 * 当用户滚动到建议列表底部时加载更多建议
 */
const handleInfiniteScroll = (direction: ScrollbarDirection) => {
  if (direction !== "bottom") return;
  const { query, items } = suggestion;
  emit("infiniteScroll", query, items);
};
script><template>
  <article :class="$style.mentionEditor" @click="handleClickContainer">
    <EditorContent :editor="editor" />
    <div
      v-if="isShowPopper"
      ref="floating-element"
      :class="$style.mentionPopper"
      :style="floatingStyles"
    >
      <ElScrollbar max-height="30vh" :distance="10" @end-reached="handleInfiniteScroll">
        <div :class="$style.mentionList">
          <button
            v-for="(item, index) in suggestion?.items"
            :key="index"
            type="button"
            :class="[$style.mentionItem, { [$style.isActive]: index === selectedIndex }]"
            @click="handleClickItem(item)"
          >
            {{ item.label }}
          button>
        div>
      ElScrollbar>
    div>
  article>
template><style module>
.mentionEditor {
  border: 1px solid var(--el-border-color);
  border-radius: 0.375rem;
  padding: 0.5rem 0.75rem;
}.mentionEditor :global(.tiptap:focus) {
  outline: none;
}.mentionEditor:focus-within {
  border-color: var(--el-color-primary);
  outline: none;
}.mentionEditor :global(.tiptap) {
  [data-type="mention"] {
    color: var(--el-color-primary);
  }  p {
    margin: 0;
    line-height: 1.5;
  }
}.mentionPopper {
  position: fixed;
  border-radius: 0.25rem;
  background-color: white;
  box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.1);
}.mentionList {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 4px;
}/* 建议项样式 */
.mentionItem {
  border-radius: 0.25rem;
  padding: 0.25rem 0.35rem;
  transition: background-color 100ms;
  border: none;
  outline: none;
  display: flex;
  align-items: center;
  min-width: 5rem;
  cursor: pointer;
  font-size: 0.85rem;
}/* 建议项悬停状态 */
.mentionItem:hover {
  background-color: rgb(243 244 246);
}/* 建议项激活状态(键盘选中) */
.mentionItem.isActive {
  background-color: rgb(55 65 81);
  color: white;
}
style>

配套的工具类型定义同样简洁明了——

/** 可能是 Promise 的类型 */
export type MaybePromise = T | Promise;/**
 * 提及选项数据结构
 * 用于表示一个可被提及的用户或项目
 */
export interface Suggestion {
  /** 唯一标识符 */
  id: string;
  /** 显示标签 */
  label: string;
}

另外值得一提的是,Element Plus 虽然也提供了自己的提及组件,但它并非富文本形式。这意味着如果采用 Element Plus 的方案,后续要追踪文本中具体提到了哪些人,只能依赖正则表达式匹配人名,在灵活性和用户体验上远不如富文本方案直接高效。

来源:https://juejin.cn/post/7644459679272845312

游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。

同类文章
更多
如何用拖放在Flask看板应用中更新数据库状态

如何用拖放在Flask看板应用中更新数据库状态

在Flask看板应用中,通过HTML5拖放API结合前端fetch异步POST请求,将拖拽任务的task_id和target_state发送至后端路由;后端接收JSON后更新数据库并提交事务,实现跨列拖拽时实时更新状态。需注意将事件绑定到容器而非子元素,并在ondragover中阻止默认行为以确保drop触发。

时间:2026-07-09 07:01
如何用CSS :has()选择器快速实现悬停span显示相邻div

如何用CSS :has()选择器快速实现悬停span显示相邻div

利用CSS的:has()伪类选择器,通过` bar:has(> baz:hover)+ qux`实现悬停内联元素时显示相邻块级容器,解决了传统相邻兄弟选择器无法跨层级匹配的局限,可用于悬停图标显示详情框等交互场景。需注意浏览器兼容性和性能优化,避免过度使用影响渲染效率。

时间:2026-07-09 07:01
display:block居中段落文本的响应式实现方法

display:block居中段落文本的响应式实现方法

通过`display:block`与`max-width`限制宽度,配合`margin:auto`使段落块水平居中并实现响应式布局;同时用CSS统一控制段落间距,避免脱离文档流,提升可读性与维护性。

时间:2026-07-09 07:01
JavaScript代码去重:高效避免重复编写的技巧

JavaScript代码去重:高效避免重复编写的技巧

将重复代码中的变化点提取为参数,利用ES6模板字符串动态生成选择器,可消除重复、保持一致性。注意参数安全性,必要时进行白名单校验;批量化场景可扩展为数组形式;现代框架中更推荐数据驱动或组件化封装替代手动选择器。

时间:2026-07-09 07:00
使用容器查询单位cqw实现表单输入框相对于祖父容器百分比宽度

使用容器查询单位cqw实现表单输入框相对于祖父容器百分比宽度

利用CSS容器查询单位cqw,表单输入框宽度可直接基于祖父容器计算,绕过中间包装器尺寸干扰,实现简洁响应式布局,无需硬编码或JavaScript,显著降低维护成本,提升嵌套结构下的布局灵活性。

时间:2026-07-09 07:00
热门专题
更多
刀塔传奇破解版无限钻石下载大全 刀塔传奇破解版无限钻石下载大全
洛克王国正式正版手游下载安装大全 洛克王国正式正版手游下载安装大全
思美人手游下载专区 思美人手游下载专区
好玩的阿拉德之怒游戏下载合集 好玩的阿拉德之怒游戏下载合集
不思议迷宫手游下载合集 不思议迷宫手游下载合集
百宝袋汉化组游戏最新合集 百宝袋汉化组游戏最新合集
jsk游戏合集30款游戏大全 jsk游戏合集30款游戏大全
宾果消消消原版下载大全 宾果消消消原版下载大全
  • 热门数据榜