在Vue中實現(xiàn)輸入框@人功能的完整代碼示例
核心是利用 tiptap 的 mention 插件實現(xiàn)。@人名 顯示藍(lán)色,普通文字顯示黑色。
這個組件導(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)聽外部內(nèi)容變化,同步到編輯器 */
watch(modelValue, (value) => {
if (value === editor.value?.getHTML()) return;
editor.value?.commands.setContent(value || "", { emitUpdate: false });
});
/** 當(dāng)前 TipTap 建議對象,包含觸發(fā)提及的位置等信息 */
const suggestion = reactive<Partial<SuggestionProps<Suggestion>>>({});
/**
* 計算提及觸發(fā)的參考元素
* 用于定位建議彈窗
*/
const reference = computed(() => {
const { decorationNode } = suggestion;
if (!(decorationNode instanceof HTMLElement)) return;
return decorationNode;
});
/** 檢測參考元素是否在視口中可見 */
const isMentionVisible = useElementVisibility(reference);
const floating = useTemplateRef("floating-element");
/**
* 計算是否應(yīng)該顯示建議彈窗
*/
const isShowPopper = computed(() => {
const { items } = suggestion;
return items?.length && reference.value && isMentionVisible.value;
});
/** 當(dāng)前選中的建議索引 */
const selectedIndex = ref(0);
/**
* 更新選中的建議索引并滾動到對應(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è)置自動更新并重置選中索引
*/
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);
},
/**
* 自定義建議列表渲染器
* 處理建議列表的顯示、隱藏和鍵盤導(dǎo)航
*/
render: () => {
return {
/** 開始顯示建議時觸發(fā) */
onStart(props) {
startUpdatePosition(props);
},
/** 建議更新時觸發(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;
// 上箭頭:選擇上一項
if (event.key === "ArrowUp") {
changeSelectedIndex((current + length - 1) % length);
return true;
}
// 下箭頭:選擇下一項
if (event.key === "ArrowDown") {
changeSelectedIndex((current + 1) % length);
return true;
}
// 回車:選擇當(dāng)前項
if (event.key === "Enter") {
const item = items[current];
if (item) handleClickItem(item);
return true;
}
return false;
},
/** 退出建議狀態(tài)時觸發(fā) */
onExit() {
suggestion.items = undefined;
},
};
},
},
});
/**
* 遞歸查找文檔中的所有提及節(jié)點
*
* 該函數(shù)遍歷 TipTap JSON 文檔樹,提取所有類型為 "mention" 的節(jié)點,
* 并將其 id 和 label 屬性收集到結(jié)果數(shù)組中。支持單個節(jié)點或節(jié)點數(shù)組作為輸入。
*
* @param doc - TipTap JSON 文檔對象或節(jié)點數(shù)組,undefined 時函數(shù)直接返回
* @param result - 用于收集提及數(shù)據(jù)的結(jié)果數(shù)組,函數(shù)會將找到的提及節(jié)點追加到此數(shù)組
*
* @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;
}
};
/**
* 創(chuàng)建 TipTap 編輯器實例
* 配置基礎(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é)點并更新提及列表
const list: Suggestion[] = [];
findMention(editor.value.getJSON(), list);
emit("updateMentions", list);
}, 200),
});
/**
* 處理編輯器容器的點擊事件
* 在編輯器未聚焦時點擊容器將聚焦到編輯器末尾
*
* @param event - 鼠標(biāo)點擊事件
*/
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");
};
/**
* 處理建議項的點擊事件
* 執(zhí)行 TipTap 的提及命令來插入選中的建議項
*
* @param item - 被選中的建議項
*/
const handleClickItem = (item: Suggestion) => {
const { command } = suggestion;
if (command) command(item);
};
/**
* 處理無限滾動事件
* 當(dāng)用戶滾動到建議列表底部時加載更多建議
*/
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;
}
/* 建議項懸停狀態(tài) */
.mentionItem:hover {
background-color: rgb(243 244 246);
}
/* 建議項激活狀態(tài)(鍵盤選中) */
.mentionItem.isActive {
background-color: rgb(55 65 81);
color: white;
}
</style>
一些工具類型。
/** 可能是 Promise 的類型 */
export type MaybePromise<T> = T | Promise<T>;
/**
* 提及選項數(shù)據(jù)結(jié)構(gòu)
* 用于表示一個可被提及的用戶或項目
*/
export interface Suggestion {
/** 唯一標(biāo)識符 */
id: string;
/** 顯示標(biāo)簽 */
label: string;
}Element Plus 也有一個提及組件:Mention 提及 | Element Plus。但這個組件不是富文本。后期無法直接追蹤文本中具體提及了哪些人,需要用正則匹配出人名。
總結(jié)
到此這篇關(guān)于在Vue中實現(xiàn)輸入框@人功能的文章就介紹到這了,更多相關(guān)Vue輸入框@人功能內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
vue + webpack如何繞過QQ音樂接口對host的驗證詳解
這篇文章主要給大家介紹了關(guān)于利用vue + webpack如何繞過QQ音樂接口對host的驗證的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),需要的朋友可以參考借鑒,下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2018-07-07
前端Vue3項目打包成Docker鏡像運行的詳細(xì)步驟
將Vue3項目打包、編寫Dockerfile、構(gòu)建Docker鏡像和運行容器是部署Vue3項目到Docker的主要步驟,這篇文章主要介紹了前端Vue3項目打包成Docker鏡像運行的詳細(xì)步驟,需要的朋友可以參考下2024-09-09

