feat(editor): 添加提及功能组件并使用floating-ui优化定位
添加提及功能的Vue组件和实现逻辑,使用@floating-ui/dom库优化弹窗定位 重构Tiptap编辑器的提及功能实现,将逻辑抽离为独立组件 更新package.json添加@floating-ui/dom依赖
This commit is contained in:
parent
c3abd733ad
commit
3363f23ad3
@ -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",
|
||||
|
@ -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':
|
||||
|
161
src/components/editor/MentionList.vue
Normal file
161
src/components/editor/MentionList.vue
Normal 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>
|
@ -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>
|
111
src/components/editor/suggestion.js
Normal file
111
src/components/editor/suggestion.js
Normal 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()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user