yink #17
| @ -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