diff --git a/package.json b/package.json index f0a1f0a..8118e39 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ded82e..d6511c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/src/components/editor/MentionList.vue b/src/components/editor/MentionList.vue new file mode 100644 index 0000000..5062e0b --- /dev/null +++ b/src/components/editor/MentionList.vue @@ -0,0 +1,161 @@ + + + + + \ No newline at end of file diff --git a/src/components/editor/TiptapEditor.vue b/src/components/editor/TiptapEditor.vue index 12e0ffd..910ae5b 100644 --- a/src/components/editor/TiptapEditor.vue +++ b/src/components/editor/TiptapEditor.vue @@ -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 = `${item.nickname}` - 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; - } - } - } } \ No newline at end of file diff --git a/src/components/editor/suggestion.js b/src/components/editor/suggestion.js new file mode 100644 index 0000000..2c43b77 --- /dev/null +++ b/src/components/editor/suggestion.js @@ -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() + }, + } + }, +} \ No newline at end of file diff --git a/src/utils/auth.js b/src/utils/auth.js index c218336..a9815a7 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -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' } /**