feat(editor): 添加提及功能组件并使用floating-ui优化定位

添加提及功能的Vue组件和实现逻辑,使用@floating-ui/dom库优化弹窗定位
重构Tiptap编辑器的提及功能实现,将逻辑抽离为独立组件
更新package.json添加@floating-ui/dom依赖
This commit is contained in:
Phoenix 2025-07-07 09:25:49 +08:00
parent c3abd733ad
commit 3363f23ad3
6 changed files with 308 additions and 160 deletions

View File

@ -15,6 +15,7 @@
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@floating-ui/dom": "^1.7.2",
"@highlightjs/vue-plugin": "^2.1.0",
"@iconify-json/ion": "^1.2.3",
"@kangc/v-md-editor": "^2.3.18",

View File

@ -11,6 +11,9 @@ importers:
'@ant-design/icons-vue':
specifier: ^7.0.1
version: 7.0.1(vue@3.5.17(typescript@5.2.2))
'@floating-ui/dom':
specifier: ^1.7.2
version: 1.7.2
'@highlightjs/vue-plugin':
specifier: ^2.1.0
version: 2.1.0(highlight.js@11.11.1)(vue@3.5.17(typescript@5.2.2))
@ -568,6 +571,15 @@ packages:
cpu: [x64]
os: [win32]
'@floating-ui/core@1.7.2':
resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==}
'@floating-ui/dom@1.7.2':
resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==}
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@hapi/hoek@9.3.0':
resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==}
@ -4280,6 +4292,17 @@ snapshots:
'@esbuild/win32-x64@0.25.5':
optional: true
'@floating-ui/core@1.7.2':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.2':
dependencies:
'@floating-ui/core': 1.7.2
'@floating-ui/utils': 0.2.10
'@floating-ui/utils@0.2.10': {}
'@hapi/hoek@9.3.0': {}
'@hapi/topo@5.1.0':

View File

@ -0,0 +1,161 @@
<template>
<div class="dropdown-menu">
<n-virtual-list
ref="virtualListRef"
style="max-height: 240px"
:item-size="50"
:items="props.items"
>
<template #default="{ item }">
<button
:class="{ 'is-selected': props.items[selectedIndex] === item }"
@click="selectItem(item)"
>
<img :src="item.avatar" class="avatar" />
<span class="nickname">{{ item.nickname }}</span>
</button>
</template>
</n-virtual-list>
</div>
</template>
<script setup>
import { ref, watch, defineProps, defineExpose } from 'vue'
const props = defineProps({
items: {
type: Array,
required: true
},
command: {
type: Function,
required: true
}
})
const selectedIndex = ref(0)
const virtualListRef = ref(null)
watch(
() => props.items,
() => {
selectedIndex.value = 0
}
)
const onKeyDown = ({ event }) => {
console.log('event',event)
if (event.key === 'ArrowUp') {
upHandler()
return true
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter') {
enterHandler()
return true
}
return false
}
const upHandler = () => {
selectedIndex.value =
(selectedIndex.value + props.items.length - 1) % props.items.length
virtualListRef.value?.scrollTo({ index: selectedIndex.value })
}
const downHandler = () => {
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
virtualListRef.value?.scrollTo({ index: selectedIndex.value })
}
const enterHandler = () => {
selectItem(props.items[selectedIndex.value])
}
const selectItem = item => {
if (item) {
props.command({ id: item.id, label: item.nickname })
}
}
defineExpose({
onKeyDown
})
</script>
<style lang="scss">
.dropdown-menu {
background: var(--white, #fff);
border: 1px solid var(--gray-1, #e0e0e0);
border-radius: 0.7rem;
box-shadow: var(--shadow, 0 2px 12px 0 rgba(0, 0, 0, 0.1));
display: flex;
flex-direction: column;
gap: 0.1rem;
overflow: auto;
padding: 0.4rem;
position: relative;
max-height: 200px;
width: 200px;
button {
align-items: center;
background-color: transparent;
display: flex;
gap: 0.25rem;
text-align: left;
width: 100%;
padding: 5px 10px;
border: none;
cursor: pointer;
&:hover,
&:hover.is-selected {
background-color: var(--gray-3, #f5f7fa);
}
&.is-selected {
background-color: var(--gray-2, #f0f0f0);
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 8px;
}
.nickname {
font-size: 14px;
}
}
}
/* 暗色模式下的样式调整 */
html[theme-mode='dark'] {
.dropdown-menu {
background-color: #1e1e1e;
border-color: #333;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.3);
button {
&:hover,
&:hover.is-selected {
background-color: #2c2c2c;
}
&.is-selected {
background-color: #333;
}
.nickname {
color: #e0e0e0;
}
}
}
}
</style>

View File

@ -5,6 +5,7 @@ import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import Mention from '@tiptap/extension-mention'
import { computePosition, flip, shift } from '@floating-ui/dom'
import Link from '@tiptap/extension-link'
import { Extension, Node } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
@ -36,6 +37,8 @@ import { EditorConst } from '@/constant/event-bus'
import { emitCall } from '@/utils/common'
//
import { defAvatar } from '@/constant/default'
//
import suggestion from './suggestion.js'
//
import MeEditorVote from './MeEditorVote.vue' //
import MeEditorEmoticon from './MeEditorEmoticon.vue' //
@ -205,118 +208,15 @@ const editor = useEditor({
class: 'mention',
},
suggestion: {
allowedPrefixes: null,
hideOnClickOutside: true,
hideOnKeyDown: true,
emptyQueryClass: 'is-empty-query',
...suggestion,
items: ({ query }) => {
if (!props.members.length) {
return []
}
let list = [...props.members]
if ((dialogueStore.groupInfo).is_manager) {
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' })
}
const filteredItems = list.filter(
(item) => item.nickname.toLowerCase().includes(query.toLowerCase())
)
//
if (filteredItems.length === 0) {
return []
}
return filteredItems
},
render: () => {
let component
let popup
let handleClickOutside
return {
onStart: (props) => {
//
popup = document.createElement('div')
popup.classList.add('ql-mention-list-container', 'me-scrollbar', 'me-scrollbar-thumb')
document.body.appendChild(popup)
//
handleClickOutside = (event) => {
if (popup && !popup.contains(event.target)) {
popup.remove()
document.removeEventListener('click', handleClickOutside)
}
}
// 使setTimeout
setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 100)
//
props.items.forEach((item, index) => {
const mentionItem = document.createElement('div')
mentionItem.classList.add('ed-member-item')
mentionItem.innerHTML = `<img src="${item.avatar}" class="avator"/><span class="nickname">${item.nickname}</span>`
mentionItem.addEventListener('click', () => {
props.command({ id: item.id, label: item.nickname })
})
if (index === props.selectedIndex) {
mentionItem.classList.add('selected')
}
popup.appendChild(mentionItem)
})
//
const coords = props.clientRect()
popup.style.position = 'fixed'
popup.style.top = `${coords.top + window.scrollY}px`
popup.style.left = `${coords.left + window.scrollX}px`
},
onUpdate: (props) => {
//
const items = popup.querySelectorAll('.ed-member-item')
items.forEach((item, index) => {
if (index === props.selectedIndex) {
item.classList.add('selected')
} else {
item.classList.remove('selected')
}
})
},
onKeyDown: (props) => {
//
// Escape
if (props.event.key === 'Escape') {
popup.remove()
return true
}
//
const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab']
if (!navigationKeys.includes(props.event.key) && props.items.length === 0) {
popup.remove()
return false
}
return false
},
onExit: () => {
//
if (popup) {
popup.remove()
//
document.removeEventListener('click', handleClickOutside)
}
},
}
return suggestion.items({
query,
props: {
members: props.members,
isGroupManager: (dialogueStore.groupInfo).is_manager
}
})
},
},
}),
@ -1171,39 +1071,6 @@ html[theme-mode='dark'] {
}
/* 提及列表样式 */
.ql-mention-list-container {
width: 270px;
max-height: 200px;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border-radius: 4px;
overflow-y: auto;
z-index: 10000;
.ed-member-item {
display: flex;
align-items: center;
padding: 5px 10px;
cursor: pointer;
&:hover, &.selected {
background-color: #f5f7fa;
}
.avator {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 8px;
}
.nickname {
font-size: 14px;
}
}
}
/* 暗色模式下的样式调整 */
html[theme-mode='dark'] {
.tiptap-editor {
@ -1215,20 +1082,5 @@ html[theme-mode='dark'] {
background-color: var(--im-message-bg-color);
}
}
.ql-mention-list-container {
background-color: #1e1e1e;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.3);
.ed-member-item {
&:hover, &.selected {
background-color: #2c2c2c;
}
.nickname {
color: #e0e0e0;
}
}
}
}
</style>

View File

@ -0,0 +1,111 @@
import { computePosition, flip, shift } from '@floating-ui/dom'
import { posToDOMRect, VueRenderer } from '@tiptap/vue-3'
import MentionList from './MentionList.vue'
import { defAvatar } from '@/constant/default'
const updatePosition = (editor, element) => {
const virtualElement = {
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
}
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'absolute',
middleware: [shift(), flip()],
}).then(({ x, y, strategy }) => {
element.style.position = strategy
if (window.__POWERED_BY_WUJIE__) {
element.style.left = `${x + 200}px`
element.style.top = `${y + 100}px`
} else {
element.style.left = `${x}px`
element.style.top = `${y}px`
}
})
}
export default {
items: ({ query, editor, props }) => {
if (!props.members || !props.members.length) {
return []
}
let list = [...props.members]
// 如果是群组管理员,添加"所有人"选项
if (props.isGroupManager) {
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar })
}
const filteredItems = list.filter(
(item) => item.nickname.toLowerCase().includes(query.toLowerCase())
)
// 如果没有匹配项,返回空数组以关闭弹窗
if (filteredItems.length === 0) {
return []
}
return filteredItems
},
render: () => {
let component
return {
onStart: props => {
// 如果没有匹配项,不创建弹窗
if (!props.items || props.items.length === 0) {
return
}
component = new VueRenderer(MentionList, {
// Vue 3 props格式
props,
editor: props.editor,
})
if (!props.clientRect) {
return
}
component.element.style.position = 'absolute'
document.body.appendChild(component.element)
updatePosition(props.editor, component.element)
},
onUpdate(props) {
component.updateProps(props)
if (props.items.length === 0) {
this.onExit()
return
}
if (!props.clientRect) {
return
}
updatePosition(props.editor, component.element)
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
this.onExit()
return true
}
return component.ref.onKeyDown(props)
},
onExit() {
component.element.remove()
component.destroy()
},
}
},
}

View File

@ -18,7 +18,7 @@ export function isLoggedIn() {
*/
export function getAccessToken() {
// return storage.get(AccessToken) || ''
return JSON.parse(localStorage.getItem('token'))||'46d71a72d8d845ad7ed23eba9bdde260e635407190c2ce1bf7fd22088e41682ea07773ec65cae8946d2003f264d55961f96e0fc5da10eb96d3a348c1664e9644ce2108c311309f398ae8ea1b8200bfd490e5cb6e8c52c9e5d493cbabb163368f8351420451a631dbfa749829ee4cda49b77b5ed2d3dced5d0f2b7dd9ee76ba5465c84a17c23af040cd92b6b2a4ea48befbb5c729dcdad0a9c9668befe84074cc24f78899c1d947f8e7f94c7eda5325b8ed698df729e76febb98549ef3482ae942fb4f4a1c92d21836fa784728f0c5483aab2760a991b6b36e6b10c84f840a6433a6ecc31dee36e8f1c6158818bc89d22eec7a138bb20774ef183e109945229d43e1f63fb01cdee46f5f663037f4ed946a0c04441b1f642c945d218180e84e91d272dc621be157602785ef226dd21b9b6c92c292bc73be90fad0320bad0812e11'
return JSON.parse(localStorage.getItem('token'))||'46d71a72d8d845ad7ed23eba9bdde260e635407190c2ce1bf7fd22088e41682ea07773ec65cae8946d2003f264d55961f96e0fc5da10eb96d3a348c1664e9644ce2108c311309f398ae8ea1b8200bfd490e5cb6e8c52c9e5d493cbabb163368f8351420451a631dbfa749829ee4cda49b77b5ed2d3dced5d0f2b7dd9ee76ba5465c84a17c23af040cd92b6b2a4ea48befbb5c729dcdad0a9c9668befe84074cc24f78899c1d947f8e7f94c7eda5325b8ed698df729e76febb98549ef3482ae942fb4f4a1c92d21836fa784728f0c5483aab2760a991b6b36e6b10c84f840a6433a6ecc31dee36e8f1c6158818bc89d220365eb2ca93ef31880576e2aa3ca8c45a705b447d40e300a54644829e2da528ea463bd2581a396336ed74880960d35716f5f7594e5b8cbb597027c6133b97b12df23427ca728fd2625977a0658ab470d'
}
/**