最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

在Vue中實(shí)現(xiàn)輸入框@人功能的完整代碼示例

 更新時(shí)間:2026年06月22日 10:35:22   作者:Fisschl  
這篇文章主要介紹了在Vue中實(shí)現(xiàn)輸入框@人功能的完整代碼示例,利用tiptap的mention插件實(shí)現(xiàn)@人名藍(lán)色顯示、普通文字黑色顯示,需要的朋友可以參考下

核心是利用 tiptap 的 mention 插件實(shí)現(xiàn)。@人名 顯示藍(lán)色,普通文字顯示黑色。

這個(gè)組件導(dǎo)出的是 HTML 格式的富文本,而不是純文本。

<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<{
  /** 獲取建議列表的函數(shù) */
  fetchSuggestions: (query: string) => MaybePromise<Suggestion[]>;
}>();

/** 編輯器 HTML 內(nèi)容的雙向綁定 */
const modelValue = defineModel<string>();

const emit = defineEmits<{
  infiniteScroll: [query: string | undefined, items: Suggestion[] | undefined];
  updateMentions: [list: Suggestion[]];
}>();

/** 監(jiān)聽(tīng)外部?jī)?nèi)容變化,同步到編輯器 */
watch(modelValue, (value) => {
  if (value === editor.value?.getHTML()) return;
  editor.value?.commands.setContent(value || "", { emitUpdate: false });
});

/** 當(dāng)前 TipTap 建議對(duì)象,包含觸發(fā)提及的位置等信息 */
const suggestion = reactive<Partial<SuggestionProps<Suggestion>>>({});

/**
 * 計(jì)算提及觸發(fā)的參考元素
 * 用于定位建議彈窗
 */
const reference = computed(() => {
  const { decorationNode } = suggestion;
  if (!(decorationNode instanceof HTMLElement)) return;
  return decorationNode;
});

/** 檢測(cè)參考元素是否在視口中可見(jiàn) */
const isMentionVisible = useElementVisibility(reference);
const floating = useTemplateRef("floating-element");

/**
 * 計(jì)算是否應(yīng)該顯示建議彈窗
 */
const isShowPopper = computed(() => {
  const { items } = suggestion;
  return items?.length && reference.value && isMentionVisible.value;
});

/** 當(dāng)前選中的建議索引 */
const selectedIndex = ref(0);

/**
 * 更新選中的建議索引并滾動(dòng)到對(duì)應(yīng)元素
 *
 * @param index - 新的選中索引
 */
const changeSelectedIndex = (index: number) => {
  selectedIndex.value = index;
  const list = floating.value?.querySelectorAll(`[type="button"]`);
  if (!list) return;
  list[index]?.scrollIntoView({
    block: "nearest",
    behavior: "smooth",
  });
};

const { floatingStyles } = useFloating(reference, floating, {
  whileElementsMounted: autoUpdate,
  middleware: [
    offset(4),
    autoPlacement({
      allowedPlacements: ["bottom-start", "top-start", "bottom-end", "top-end"],
      padding: 4,
    }),
  ],
});

/**
 * 設(shè)置自動(dòng)更新并重置選中索引
 */
const startUpdatePosition = (value: SuggestionProps) => {
  Object.assign(suggestion, omit(value, ["editor"]));
  changeSelectedIndex(0);
};

const mentionExtension = Mention.configure({
  deleteTriggerWithBackspace: true,
  suggestion: {
    allowedPrefixes: null,
    /**
     * 獲取建議項(xiàng)列表
     * @returns 建議項(xiàng)列表
     */
    items: ({ query }) => {
      return props.fetchSuggestions(query);
    },
    /**
     * 自定義建議列表渲染器
     * 處理建議列表的顯示、隱藏和鍵盤導(dǎo)航
     */
    render: () => {
      return {
        /** 開(kāi)始顯示建議時(shí)觸發(fā) */
        onStart(props) {
          startUpdatePosition(props);
        },
        /** 建議更新時(shí)觸發(fā) */
        onUpdate(props) {
          startUpdatePosition(props);
        },
        /** 鍵盤事件處理 */
        onKeyDown({ event }) {
          // ESC 鍵:關(guān)閉建議
          if (event.key === "Escape") {
            suggestion.items = undefined;
            return true;
          }

          const items = suggestion.items || [];
          const length = items.length;
          const current = selectedIndex.value;

          // 上箭頭:選擇上一項(xiàng)
          if (event.key === "ArrowUp") {
            changeSelectedIndex((current + length - 1) % length);
            return true;
          }

          // 下箭頭:選擇下一項(xiàng)
          if (event.key === "ArrowDown") {
            changeSelectedIndex((current + 1) % length);
            return true;
          }

          // 回車:選擇當(dāng)前項(xiàng)
          if (event.key === "Enter") {
            const item = items[current];
            if (item) handleClickItem(item);
            return true;
          }
          return false;
        },
        /** 退出建議狀態(tài)時(shí)觸發(fā) */
        onExit() {
          suggestion.items = undefined;
        },
      };
    },
  },
});

/**
 * 遞歸查找文檔中的所有提及節(jié)點(diǎn)
 *
 * 該函數(shù)遍歷 TipTap JSON 文檔樹(shù),提取所有類型為 "mention" 的節(jié)點(diǎn),
 * 并將其 id 和 label 屬性收集到結(jié)果數(shù)組中。支持單個(gè)節(jié)點(diǎn)或節(jié)點(diǎn)數(shù)組作為輸入。
 *
 * @param doc - TipTap JSON 文檔對(duì)象或節(jié)點(diǎn)數(shù)組,undefined 時(shí)函數(shù)直接返回
 * @param result - 用于收集提及數(shù)據(jù)的結(jié)果數(shù)組,函數(shù)會(huì)將找到的提及節(jié)點(diǎn)追加到此數(shù)組
 *
 * @example
 * ```typescript
 * const mentions: Suggestion[] = [];
 * const json = editor.getJSON();
 * findMention(json, mentions);
 * console.log(`找到 ${mentions.length} 個(gè)提及`);
 * ```
 */
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;
  }
};

/**
 * 創(chuàng)建 TipTap 編輯器實(shí)例
 * 配置基礎(chǔ)的文檔結(jié)構(gòu)、段落、文本和提及功能
 */
const editor = useEditor({
  extensions: [Document, Paragraph, Text, mentionExtension],
  content: modelValue.value || "",
  onUpdate: useDebounceFn(() => {
    if (!editor.value) return;
    modelValue.value = editor.value.getHTML() || "";
    // 提取所有提及節(jié)點(diǎn)并更新提及列表
    const list: Suggestion[] = [];
    findMention(editor.value.getJSON(), list);
    emit("updateMentions", list);
  }, 200),
});

/**
 * 處理編輯器容器的點(diǎn)擊事件
 * 在編輯器未聚焦時(shí)點(diǎn)擊容器將聚焦到編輯器末尾
 *
 * @param event - 鼠標(biāo)點(diǎn)擊事件
 */
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");
};

/**
 * 處理建議項(xiàng)的點(diǎn)擊事件
 * 執(zhí)行 TipTap 的提及命令來(lái)插入選中的建議項(xiàng)
 *
 * @param item - 被選中的建議項(xiàng)
 */
const handleClickItem = (item: Suggestion) => {
  const { command } = suggestion;
  if (command) command(item);
};

/**
 * 處理無(wú)限滾動(dòng)事件
 * 當(dāng)用戶滾動(dòng)到建議列表底部時(shí)加載更多建議
 */
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;
}

/* 建議項(xiàng)樣式 */
.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;
}

/* 建議項(xiàng)懸停狀態(tài) */
.mentionItem:hover {
  background-color: rgb(243 244 246);
}

/* 建議項(xiàng)激活狀態(tài)(鍵盤選中) */
.mentionItem.isActive {
  background-color: rgb(55 65 81);
  color: white;
}
</style>

一些工具類型。

/** 可能是 Promise 的類型 */
export type MaybePromise<T> = T | Promise<T>;
/**
 * 提及選項(xiàng)數(shù)據(jù)結(jié)構(gòu)
 * 用于表示一個(gè)可被提及的用戶或項(xiàng)目
 */
export interface Suggestion {
  /** 唯一標(biāo)識(shí)符 */
  id: string;
  /** 顯示標(biāo)簽 */
  label: string;
}

Element Plus 也有一個(gè)提及組件:Mention 提及 | Element Plus。但這個(gè)組件不是富文本。后期無(wú)法直接追蹤文本中具體提及了哪些人,需要用正則匹配出人名。

總結(jié)

到此這篇關(guān)于在Vue中實(shí)現(xiàn)輸入框@人功能的文章就介紹到這了,更多相關(guān)Vue輸入框@人功能內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!

相關(guān)文章

  • 詳解Vue3-pinia狀態(tài)管理

    詳解Vue3-pinia狀態(tài)管理

    這篇文章主要介紹了Vue3-pinia狀態(tài)管理,pinia是?vue3?新的狀態(tài)管理工具,簡(jiǎn)單來(lái)說(shuō)相當(dāng)于之前?vuex,它去掉了?Mutations?但是也是支持?vue2?的,需要的朋友可以參考下
    2022-08-08
  • vue2 webpack proxy與nginx配置方式

    vue2 webpack proxy與nginx配置方式

    這篇文章主要介紹了vue2 webpack proxy與nginx配置方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2022-10-10
  • Vue3響應(yīng)式高階用法之toRaw()的使用

    Vue3響應(yīng)式高階用法之toRaw()的使用

    在Vue3中,toRaw方法允許用戶獲取響應(yīng)式對(duì)象的原始值,有助于性能優(yōu)化和與外部庫(kù)集成,它通過(guò)繞過(guò)Vue的響應(yīng)式系統(tǒng),僅在必要時(shí)觸發(fā)更新,從而提升效率,本文就來(lái)具體介紹一下,感興趣的可以了解一下
    2024-09-09
  • vue + webpack如何繞過(guò)QQ音樂(lè)接口對(duì)host的驗(yàn)證詳解

    vue + webpack如何繞過(guò)QQ音樂(lè)接口對(duì)host的驗(yàn)證詳解

    這篇文章主要給大家介紹了關(guān)于利用vue + webpack如何繞過(guò)QQ音樂(lè)接口對(duì)host的驗(yàn)證的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),需要的朋友可以參考借鑒,下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧
    2018-07-07
  • 詳解element-ui中form驗(yàn)證雜記

    詳解element-ui中form驗(yàn)證雜記

    這篇文章主要介紹了詳解element-ui中form驗(yàn)證雜記,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧
    2019-03-03
  • 封裝一個(gè)Vue文件上傳組件案例詳情

    封裝一個(gè)Vue文件上傳組件案例詳情

    這篇文章主要介紹了封裝一個(gè)Vue文件上傳組件案例詳情,文章圍繞主題展開(kāi)詳細(xì)的內(nèi)容介紹,具有一定的參考價(jià)值,需要的小伙伴可以參考一下
    2022-08-08
  • vue-cli 3.x 修改dist路徑的方法

    vue-cli 3.x 修改dist路徑的方法

    今天小編就為大家分享一篇vue-cli 3.x 修改dist路徑的方法,具有很好的參考價(jià)值。希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧
    2018-09-09
  • Vue element-ui父組件控制子組件的表單校驗(yàn)操作

    Vue element-ui父組件控制子組件的表單校驗(yàn)操作

    這篇文章主要介紹了Vue element-ui父組件控制子組件的表單校驗(yàn)操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧
    2020-07-07
  • 前端Vue3項(xiàng)目打包成Docker鏡像運(yùn)行的詳細(xì)步驟

    前端Vue3項(xiàng)目打包成Docker鏡像運(yùn)行的詳細(xì)步驟

    將Vue3項(xiàng)目打包、編寫Dockerfile、構(gòu)建Docker鏡像和運(yùn)行容器是部署Vue3項(xiàng)目到Docker的主要步驟,這篇文章主要介紹了前端Vue3項(xiàng)目打包成Docker鏡像運(yùn)行的詳細(xì)步驟,需要的朋友可以參考下
    2024-09-09
  • vue-cli-service的參數(shù)配置過(guò)程

    vue-cli-service的參數(shù)配置過(guò)程

    這篇文章主要介紹了vue-cli-service的參數(shù)配置過(guò)程,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2023-04-04

最新評(píng)論

兴安县| 铁力市| 方正县| 连平县| 两当县| 库伦旗| 朝阳区| 阿拉善左旗| 扎赉特旗| 宝兴县| 长宁县| 清丰县| 盘锦市| 松江区| 纳雍县| 东山县| 宁河县| 平度市| 亚东县| 侯马市| 神农架林区| 措勤县| 卢龙县| 汉川市| 岗巴县| 安顺市| 衢州市| 武穴市| 芦山县| 新田县| 白朗县| 伊宁市| 湟中县| 吉木乃县| 浦城县| 西丰县| 新闻| 阿瓦提县| 察哈| 分宜县| 龙门县|