<script setup> import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, markRaw, watch } from 'vue' import { NPopover, NIcon } from 'naive-ui' import { SmilingFace, Pic, FolderUpload } from '@icon-park/vue-next' import { bus } from '@/utils/event-bus' import { EditorConst } from '@/constant/event-bus' import { emitCall } from '@/utils/common' import { deltaToMessage, deltaToString, isEmptyDelta } from './util' import { useDialogueStore, useEditorDraftStore } from '@/store' import { uploadImg } from '@/api/upload' import { defAvatar } from '@/constant/default' import { getImageInfo } from '@/utils/functions' import MeEditorEmoticon from './MeEditorEmoticon.vue' const props = defineProps({ vote: { type: Boolean, default: false }, members: { type: Array, default: () => [] } }) const emit = defineEmits(['editor-event']) // 响应式数据 const editorRef = ref(null) const content = ref('') const isFocused = ref(false) const showMention = ref(false) const mentionQuery = ref('') const mentionPosition = ref({ top: 0, left: 0 }) const selectedMentionIndex = ref(0) const quoteData = ref(null) const editingMessage = ref(null) const isShowEmoticon = ref(false) const fileImageRef = ref(null) const uploadFileRef = ref(null) const emoticonRef = ref(null) // 获取状态管理 const dialogueStore = useDialogueStore() const editorDraftStore = useEditorDraftStore() // 当前会话索引 const indexName = computed(() => dialogueStore.index_name) // 工具栏配置 const navs = reactive([ { title: '图片', icon: markRaw(Pic), show: true, click: () => { fileImageRef.value.click() } }, { title: '文件', icon: markRaw(FolderUpload), show: true, click: () => { uploadFileRef.value.click() } } ]) const mentionList = ref([]) const currentMentionQuery = ref('') // 编辑器内容 const editorContent = ref('') const editorHtml = ref('') // 工具栏配置 const toolbarConfig = computed(() => { const config = [ { type: 'emoticon', icon: 'icon-biaoqing', title: '表情' }, { type: 'image', icon: 'icon-image', title: '图片' }, { type: 'file', icon: 'icon-folder-plus', title: '文件' } ] return config }) // 处理输入事件 const handleInput = (event) => { const target = event.target // 获取编辑器内容,但不包括引用元素的内容 const editorClone = target.cloneNode(true) const quoteElements = editorClone.querySelectorAll('.editor-quote') quoteElements.forEach(quote => quote.remove()) // 处理表情图片,将其 alt 属性(表情文本)添加到文本内容中 const emojiImages = editorClone.querySelectorAll('img.editor-emoji') let textContent = editorClone.textContent || '' // 将表情图片的 alt 属性添加到文本内容中 emojiImages.forEach(emoji => { const altText = emoji.getAttribute('alt') if (altText) { textContent += altText } }) // 更新编辑器内容(包含表情文本) editorContent.value = textContent editorHtml.value = target.innerHTML || '' // 检查@mention checkMention(target) // 保存草稿 saveDraft() // 发送输入事件 emit('editor-event', { event: 'input_event', data: editorContent.value }) } // 检查@mention const checkMention = (target) => { const selection = window.getSelection() if (!selection.rangeCount) return const range = selection.getRangeAt(0) const textBeforeCursor = range.startContainer.textContent?.substring(0, range.startOffset) || '' const mentionMatch = textBeforeCursor.match(/@([^@\s]*)$/) if (mentionMatch) { currentMentionQuery.value = mentionMatch[1] showMentionList() updateMentionPosition(range) } else { hideMentionList() } } // 显示mention列表 const showMentionList = () => { const query = currentMentionQuery.value.toLowerCase() mentionList.value = props.members.filter(member => member.nickname.toLowerCase().includes(query) ).slice(0, 10) showMention.value = mentionList.value.length > 0 selectedMentionIndex.value = 0 } // 隐藏mention列表 const hideMentionList = () => { showMention.value = false mentionList.value = [] currentMentionQuery.value = '' } // 更新mention位置 const updateMentionPosition = (range) => { const rect = range.getBoundingClientRect() const editorRect = editorRef.value.getBoundingClientRect() mentionPosition.value = { top: rect.bottom - editorRect.top + 5, left: rect.left - editorRect.left } } // 插入mention const insertMention = (member) => { const selection = window.getSelection() if (!selection.rangeCount) return const range = selection.getRangeAt(0) const textNode = range.startContainer const offset = range.startOffset // 找到@符号的位置 const textContent = textNode.textContent || '' const atIndex = textContent.lastIndexOf('@', offset - 1) // 创建mention元素 const mentionSpan = document.createElement('span') mentionSpan.className = 'mention' mentionSpan.setAttribute('data-user-id', member.id || member.user_id) mentionSpan.textContent = `@${member.value || member.nickname}` mentionSpan.contentEditable = 'false' if (atIndex !== -1) { // 如果找到@符号,替换文本 const beforeText = textContent.substring(0, atIndex) const afterText = textContent.substring(offset) // 创建新的文本节点 const beforeNode = document.createTextNode(beforeText) const afterNode = document.createTextNode(' ' + afterText) // 替换内容 const parent = textNode.parentNode parent.insertBefore(beforeNode, textNode) parent.insertBefore(mentionSpan, textNode) parent.insertBefore(afterNode, textNode) parent.removeChild(textNode) } else { // 如果没有找到@符号,直接在光标位置插入 range.deleteContents() // 插入@提及元素 range.insertNode(mentionSpan) // 在@提及元素后添加空格 const spaceNode = document.createTextNode(' ') range.setStartAfter(mentionSpan) range.insertNode(spaceNode) // 将光标移动到空格后 range.setStartAfter(spaceNode) range.collapse(true) selection.removeAllRanges() selection.addRange(range) } // 触发输入事件以更新编辑器内容 handleInput({ target: editorRef.value }) // 隐藏mention列表 hideMentionList() } // 处理粘贴事件 const handlePaste = (event) => { event.preventDefault() // 检查是否有图片 const items = event.clipboardData?.items if (items) { for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { // 获取粘贴的图片文件 const file = items[i].getAsFile() if (file) { // 使用现有的上传图片功能处理 onUploadFile([file]) return } } } } // 获取粘贴的纯文本内容 const text = event.clipboardData?.getData('text/plain') || '' if (text) { // 插入纯文本,移除所有样式 const selection = window.getSelection() if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0) range.deleteContents() range.insertNode(document.createTextNode(text)) range.collapse(false) selection.removeAllRanges() selection.addRange(range) // 触发输入事件 handleInput({ target: editorRef.value }) } } } // 处理键盘事件 const handleKeydown = (event) => { // 处理@提及列表的键盘导航 if (showMention.value) { switch (event.key) { case 'ArrowUp': event.preventDefault() selectedMentionIndex.value = Math.max(0, selectedMentionIndex.value - 1) break case 'ArrowDown': event.preventDefault() selectedMentionIndex.value = Math.min(mentionList.value.length - 1, selectedMentionIndex.value + 1) break case 'Enter': case 'Tab': event.preventDefault() if (mentionList.value[selectedMentionIndex.value]) { insertMention(mentionList.value[selectedMentionIndex.value]) } break case 'Escape': hideMentionList() break } return } console.log('键盘事件:', event.key, 'Ctrl:', event.ctrlKey, 'Meta:', event.metaKey); // 处理Enter键发送消息(只有在没有按Ctrl/Cmd时才发送) if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey) { console.log('Enter发送消息'); event.preventDefault() console.log('editorContent.value', editorContent.value) console.log('editorHtml.value', editorHtml.value) // 确保编辑器内容不为空(文本、图片、文件或表情) // 由于我们已经在 handleInput 中处理了表情文本,editorContent.value 应该包含表情文本 // if (editorContent.value.trim()) { if (true) { // 检查引用元素是否存在,如果不存在但 quoteData 有值,则清除 quoteData const editor = editorRef.value const quoteElement = editor?.querySelector('.editor-quote') if (!quoteElement && quoteData.value) { console.log('引用元素已被删除,但 quoteData 仍有值,清除 quoteData') quoteData.value = null } // 解析并输出编辑器内容 const messageData = parseEditorContent() console.log('编辑器内容解析结果:', JSON.stringify(messageData, null, 2)) // 输出详细信息 console.log('消息项目数量:', messageData.items.length) console.log('消息项目类型:', messageData.items.map(item => item.type)) console.log('提及用户IDs:', messageData.mentionUids) console.log('引用消息ID:', messageData.quoteId) // 继续发送消息 sendMessage() } } // Ctrl+Enter换行由WangEditor的onKeyDown处理,这里不需要额外处理 } // 发送消息 const sendMessage = () => { console.log('发送消息'); // 检查编辑器是否有内容:文本内容(包括表情文本) // if (!editorContent.value.trim()) { // return // } console.log('发送消息1'); const messageData = parseEditorContent() // 输出完整的消息数据结构 console.log('完整消息数据:', { items: messageData.items, mentionUids: messageData.mentionUids, quoteId: messageData.quoteId, quoteData: quoteData.value ? { msg_id: quoteData.value.msg_id, title: quoteData.value.title, describe: quoteData.value.describe, image: quoteData.value.image } : null }) messageData.items.forEach(item => { // 处理文本内容 if (item.type === 1) { const data={ items:[{ content:item.content, type:1 }], mentionUids:messageData.mentionUids, mentions:[], quoteId:messageData.quoteId, } console.log('发送前',data) console.log('quoteData',quoteData.value) emit( 'editor-event', emitCall('text_event', data,(ok)=>{ console.log('发送后',ok) }) ) }else if(item.type === 2){ //图片消息 }else if(item.type === 3){ console.log('发送图片消息') const data={ height:0, width:0, size:10000, url:item.content, } emit( 'editor-event', emitCall( 'image_event', data, (ok) => { // 成功发送后清空编辑器 } ) ) }else if(item.type === 4){ } }) } // 解析编辑器内容 const parseEditorContent = () => { const items = [] const mentionUids = [] // 解析HTML内容 const tempDiv = document.createElement('div') tempDiv.innerHTML = editorHtml.value // 检查是否有引用元素 const quoteElements = tempDiv.querySelectorAll('.editor-quote') let quoteInfo = null if (quoteElements.length > 0 && quoteData.value) { quoteInfo = { msg_id: quoteData.value.msg_id, title: quoteData.value.title, describe: quoteData.value.describe, image: quoteData.value.image } } let textContent = '' const nodes = Array.from(tempDiv.childNodes) nodes.forEach(node => { if (node.nodeType === Node.TEXT_NODE) { textContent += node.textContent } else if (node.nodeType === Node.ELEMENT_NODE) { // 跳过引用元素的处理,因为我们已经单独处理了 if (node.classList.contains('editor-quote')) { return } if (node.classList.contains('mention')) { const userId = node.getAttribute('data-user-id') if (userId) { mentionUids.push(parseInt(userId)) } textContent += node.textContent } else if (node.tagName === 'IMG') { // 处理图片 const src = node.getAttribute('src') const width = node.getAttribute('data-original-width') || node.getAttribute('width') || '' const height = node.getAttribute('data-original-height') || node.getAttribute('height') || '' const isEmoji = node.classList.contains('editor-emoji') if (textContent.trim()) { items.push({ type: 1, content: textContent.trim() }) textContent = '' } if (isEmoji) { // 处理表情图片 const altText = node.getAttribute('alt') || '' if (altText) { // 如果有alt文本,将表情作为文本处理 textContent += altText } else { // 否则作为图片处理 items.push({ type: 3, content: src + (width && height ? `?width=${width}&height=${height}` : ''), isEmoji: true }) } } else { // 处理普通图片 items.push({ type: 3, content: src + (width && height ? `?width=${width}&height=${height}` : ''), width: width, height: height }) } } else if (node.classList.contains('emoji')) { textContent += node.getAttribute('alt') || node.textContent } else if (node.classList.contains('editor-file')) { // 处理文件 const fileUrl = node.getAttribute('data-url') const fileName = node.getAttribute('data-name') const fileSize = node.getAttribute('data-size') if (textContent.trim()) { items.push({ type: 1, content: textContent.trim() }) textContent = '' } if (fileUrl && fileName) { items.push({ type: 'file', content: fileUrl, name: fileName, size: node.getAttribute('data-size-raw') || fileSize || 0 }) } } else { textContent += node.textContent } } }) if (textContent.trim()) { items.push({ type: 1, content: textContent.trim() }) } // 构建完整的消息数据结构 const result = { items: items.length > 0 ? items : [{ type: 1, content: '' }], mentionUids, quoteId: quoteElements.length > 0 && quoteData.value ? quoteData.value.id ||'' : '' } // 如果有引用信息,添加到结果中 if (quoteInfo) { result.quoteInfo = quoteInfo } return result } // 清空编辑器 const clearEditor = () => { editorContent.value = '' editorHtml.value = '' if (editorRef.value) { editorRef.value.innerHTML = '' } // 清除引用数据 quoteData.value = null hideMentionList() // 清空草稿 saveDraft() // 触发输入事件 emit('editor-event', { event: 'input_event', data: '' }) } // 插入图片 const insertImage = (src, width, height) => { const selection = window.getSelection() if (!selection.rangeCount) return const range = selection.getRangeAt(0) // 创建图片元素 const img = document.createElement('img') img.src = src img.className = 'editor-image' img.alt = '图片' img.style.maxHeight = '200px' img.style.maxWidth = '100%' img.style.objectFit = 'contain' // 保持原始比例 // 存储原始尺寸信息,但不直接设置宽高属性 if (width) img.setAttribute('data-original-width', width) if (height) img.setAttribute('data-original-height', height) range.deleteContents() range.insertNode(img) range.setStartAfter(img) range.collapse(true) selection.removeAllRanges() selection.addRange(range) editorRef.value.focus() handleInput({ target: editorRef.value }) } // 格式化文件大小 const formatFileSize = (size) => { if (size < 1024) { return size + ' B' } else if (size < 1024 * 1024) { return (size / 1024).toFixed(2) + ' KB' } else if (size < 1024 * 1024 * 1024) { return (size / (1024 * 1024)).toFixed(2) + ' MB' } else { return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB' } } /** * 文件上传处理 * @param e 上传事件对象或文件数组 */ // 文件上传处理 async function onUploadFile(e) { let files; // 判断参数类型 if (Array.isArray(e)) { // 直接传入的文件数组 files = e; } else { // 传入的是事件对象 files = e.target.files; e.target.value = null; // 清空input,允许再次选择相同文件 } // 确保有文件 if (!files || files.length === 0) return; // 处理第一个文件 const file = files[0]; console.log("文件类型"+file.type) if (file.type.indexOf('image/') === 0) { console.log("进入图片") // 创建临时URL const tempUrl = URL.createObjectURL(file); // 创建图片对象以获取尺寸 const image = new Image(); image.src = tempUrl; image.onload = () => { // 上传图片到服务器 const form = new FormData(); form.append('file', file); form.append("source", "fonchain-chat"); // 图片来源标识 form.append("urlParam", `width=${image.width}&height=${image.height}`); // 先将临时图片插入编辑器,不直接设置宽高,而是传递原始尺寸信息 insertImage(tempUrl, image.width, image.height); // 上传图片并获取永久URL uploadImg(form).then(({ code, data, message }) => { if (code == 0) { // 上传成功后,将临时URL替换为永久URL console.log('图片上传成功:', data.ori_url); // 查找编辑器中刚插入的图片元素并替换其src为永久URL const editorImages = editorRef.value.querySelectorAll('img.editor-image'); // 查找最后插入的图片(通常是最近添加的那个) const lastImage = editorImages[editorImages.length - 1]; if (lastImage && lastImage.src === tempUrl) { // 替换为永久URL lastImage.src = data.ori_url; // 触发输入事件更新编辑器内容 handleInput({ target: editorRef.value }); } } else { window['$message'].error(message); } }); }; return; } if (file.type.indexOf('video/') === 0) { console.log("进入视频") // 处理视频文件 let fn = emitCall('video_event', file, () => {}) emit('editor-event', fn) } else { console.log("进入其他") // 处理其他类型文件 let fn = emitCall('file_event', file, () => {}) emit('editor-event', fn) } } // 表情选择事件 const onEmoticonEvent = (emoji) => { // 关闭表情面板 emoticonRef.value?.setShow(false) // 处理不同类型的表情 if (emoji.type === 'text') { // 文本表情 insertTextEmoji(emoji.value) } else if (emoji.type === 'image') { // 图片表情 insertImageEmoji(emoji.img, emoji.value) } else if (emoji.type === 'emoji') { // 系统表情 insertTextEmoji(emoji.value) } else if (emoji.type === 1) { // 兼容旧版表情格式 if (emoji.img) { insertImageEmoji(emoji.img, emoji.value) } else { insertTextEmoji(emoji.value) } } else { // 发送整个表情包 emit('editor-event', { event: 'emoticon_event', data: emoji.value || emoji.id, callBack: () => {} }) } } // 插入文本表情 const insertTextEmoji = (emojiText) => { const editor = editorRef.value if (!editor) return editor.focus() const selection = window.getSelection() if (!selection.rangeCount) return const range = selection.getRangeAt(0) range.deleteContents() const textNode = document.createTextNode(emojiText) range.insertNode(textNode) // 移动光标到插入文本之后 range.setStartAfter(textNode) range.collapse(true) selection.removeAllRanges() selection.addRange(range) // 触发输入事件以更新编辑器内容 handleInput({ target: editor }) } // 插入图片表情 const insertImageEmoji = (imgSrc, altText) => { const editor = editorRef.value if (!editor) return editor.focus() const selection = window.getSelection() if (!selection.rangeCount) return const range = selection.getRangeAt(0) range.deleteContents() const img = document.createElement('img') img.src = imgSrc img.alt = altText img.className = 'editor-emoji' // 使用已有的样式 // img.style.width = '24px' // 样式已在 .editor-emoji 中定义 // img.style.height = '24px' // img.style.verticalAlign = 'middle' range.insertNode(img) // 移动光标到插入图片之后 range.setStartAfter(img) range.collapse(true) selection.removeAllRanges() selection.addRange(range) // 触发输入事件以更新编辑器内容 handleInput({ target: editor }) } // 事件监听 const onSubscribeMention = (data) => { // 确保编辑器获得焦点 editorRef.value?.focus() // 如果编辑器为空或者光标不在编辑器内,将光标移动到编辑器末尾 const selection = window.getSelection() if (!selection.rangeCount || !editorRef.value.contains(selection.anchorNode)) { const range = document.createRange() if (editorRef.value.lastChild) { range.setStartAfter(editorRef.value.lastChild) } else { range.setStart(editorRef.value, 0) } range.collapse(true) selection.removeAllRanges() selection.addRange(range) } // 插入@提及 insertMention(data) } const onSubscribeQuote = (data) => { // 保存引用数据,但不保存到草稿中 quoteData.value = data // 在编辑器中显示引用内容 const editor = editorRef.value if (!editor) return // 先移除已有的引用元素 const existingQuotes = editor.querySelectorAll('.editor-quote') existingQuotes.forEach(quote => quote.remove()) // 保存当前光标位置 const selection = window.getSelection() const savedRange = selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null const hasContent = editor.textContent.trim().length > 0 // 创建引用元素 const quoteElement = document.createElement('div') quoteElement.className = 'editor-quote' quoteElement.contentEditable = 'false' // 设置为不可编辑 // 添加引用内容和关闭按钮 quoteElement.innerHTML = ` <div class="quote-content-wrapper"> <div class="quote-title">${data.title}</div> ${data.image ? `<div class="quote-image"><img src="${data.image}" alt="引用图片" /></div>` : ''} ${data.describe ? `<div class="quote-content">${data.describe}</div>` : ''} </div> <div class="quote-close">×</div> ` // 将引用元素插入到编辑器开头 if (editor.firstChild) { editor.insertBefore(quoteElement, editor.firstChild) } else { editor.appendChild(quoteElement) } // 使用事件委托处理引用元素的所有点击事件 quoteElement.addEventListener('click', (e) => { console.log('执行删除',e) // 检查点击的是否是关闭按钮或其内部元素 const closeButton = e.target.classList?.contains('quote-close') ? e.target : e.target.closest('.quote-close') if (closeButton) { // 阻止事件冒泡 e.stopPropagation() // 移除引用元素 quoteElement.remove() // 清除引用数据 quoteData.value = null // 不触发handleInput,避免保存草稿 // 只更新编辑器内容变量 editorContent.value = editor.textContent || '' editorHtml.value = editor.innerHTML || '' // 确保编辑器获得焦点 setTimeout(() => { editor.focus() }, 0) } else { // 如果不是点击关闭按钮,则设置光标到引用卡片后面 const selection = window.getSelection() const range = document.createRange() range.setStartAfter(quoteElement) range.collapse(true) selection.removeAllRanges() selection.addRange(range) // 确保编辑器获得焦点 editor.focus() } }) // 注意:不调用saveDraft(),确保引用内容不会被保存到草稿中 // 在同一个事件监听器中处理引用卡片的点击 // 已经在上面的事件处理中添加了关闭按钮的处理逻辑 // 这里只需要处理非关闭按钮的点击 // 注意:由于事件委托的方式,不需要额外添加点击事件监听器 // 监听键盘事件,处理删除操作 // 监听键盘事件,处理删除操作 const handleDeleteQuote = function(e) { // 检查是否是删除键(Backspace 或 Delete) if (e.key === 'Backspace' || e.key === 'Delete') { const selection = window.getSelection(); if (selection.rangeCount === 0) return; const range = selection.getRangeAt(0); const quoteElement = editor.querySelector('.editor-quote'); if (!quoteElement) { // 如果引用元素已经被删除,移除事件监听器 editor.removeEventListener('keydown', handleDeleteQuote); return; } // 检查光标是否在引用卡片前面(Backspace)或后面(Delete) const isBeforeQuote = e.key === 'Backspace' && range.collapsed && range.startContainer === editor && Array.from(editor.childNodes).indexOf(quoteElement) === range.startOffset; const isAfterQuote = e.key === 'Delete' && range.collapsed && range.startContainer === editor && Array.from(editor.childNodes).indexOf(quoteElement) === range.startOffset - 1; if (isBeforeQuote || isAfterQuote) { quoteElement.remove(); quoteData.value = null; handleInput({ target: editor }); e.preventDefault(); } } }; editor.addEventListener('keydown', handleDeleteQuote); // 设置光标位置 setTimeout(() => { // 确保编辑器中有内容可以放置光标 if (!editor.childNodes.length || (editor.childNodes.length === 1 && editor.childNodes[0] === quoteElement)) { // 如果编辑器中只有引用元素,添加一个空的文本节点 const textNode = document.createTextNode('\u200B'); // 使用零宽空格 editor.appendChild(textNode); } // 强制刷新编辑器内容,确保DOM已更新 const currentHtml = editor.innerHTML; editor.innerHTML = currentHtml; // 重新获取引用元素(因为innerHTML操作会导致之前的引用丢失) const newQuoteElement = editor.querySelector('.editor-quote'); editor.focus(); const newSelection = window.getSelection(); const newRange = document.createRange(); // 始终将光标设置在引用元素后面 if (newQuoteElement) { // 找到引用元素后的第一个节点 let nextNode = newQuoteElement.nextSibling; if (nextNode && nextNode.nodeType === 3) { // 文本节点 newRange.setStart(nextNode, 0); } else if (nextNode) { // 其他元素节点 newRange.setStartBefore(nextNode); } else { // 没有下一个节点 newRange.setStartAfter(newQuoteElement); } } else { // 如果找不到引用元素,则设置到编辑器开头 if (editor.firstChild) { newRange.setStartBefore(editor.firstChild); } else { newRange.setStart(editor, 0); } } newRange.collapse(true); newSelection.removeAllRanges(); newSelection.addRange(newRange); // 确保光标可见 editor.scrollTop = editor.scrollHeight; // 更新编辑器内容 handleInput({ target: editor }); // 再次确保编辑器获得焦点 setTimeout(() => { editor.focus(); // 再次尝试设置光标位置 if (newSelection.rangeCount === 0) { const finalRange = document.createRange(); if (newQuoteElement) { finalRange.setStartAfter(newQuoteElement); } else { finalRange.setStart(editor, 0); } finalRange.collapse(true); newSelection.removeAllRanges(); newSelection.addRange(finalRange); } }, 50); }, 200); // 增加延时确保DOM已完全更新 } const onSubscribeEdit = (data) => { editingMessage.value = data clearEditor() // 插入编辑内容 if (data.content) { editorRef.value.innerHTML = data.content editorContent.value = data.content editorHtml.value = data.content } } // 清除编辑器内容的事件处理函数 const onSubscribeClear = () => { clearEditor() } // 保存草稿 const saveDraft = () => { if (!indexName.value) return // 获取编辑器内容,但排除引用元素 let contentToSave = '' let htmlToSave = '' if (editorRef.value) { // 临时保存引用元素 const quoteElements = [] const editorQuotes = editorRef.value.querySelectorAll('.editor-quote') // 克隆编辑器内容 const clonedEditor = editorRef.value.cloneNode(true) // 从克隆的编辑器中移除引用元素 const clonedQuotes = clonedEditor.querySelectorAll('.editor-quote') clonedQuotes.forEach(quote => quote.remove()) // 获取不包含引用的内容 contentToSave = clonedEditor.textContent || '' htmlToSave = clonedEditor.innerHTML || '' } // 检查是否有实际内容(不包括引用) const hasContent = contentToSave.trim() || htmlToSave.includes('<img') || htmlToSave.includes('editor-file') if (hasContent) { // 保存草稿到store,不包括引用数据 editorDraftStore.items[indexName.value] = JSON.stringify({ content: contentToSave, html: htmlToSave // 不保存quoteData,确保引用不会出现在草稿中 }) } else { // 编辑器为空时删除对应草稿 delete editorDraftStore.items[indexName.value] } } // 加载草稿 const loadDraft = () => { if (!indexName.value) return // 延迟处理,确保DOM已渲染 setTimeout(() => { // 保存当前引用数据的临时副本 const currentQuoteData = quoteData.value // 清除当前引用数据,避免重复添加 quoteData.value = null const draft = editorDraftStore.items[indexName.value] if (draft) { try { const draftData = JSON.parse(draft) // 恢复编辑器内容(不包含引用) if (editorRef.value) { // 先清空编辑器内容,包括引用元素 editorRef.value.innerHTML = '' // 恢复草稿内容 editorRef.value.innerHTML = draftData.html || '' editorContent.value = draftData.content || '' editorHtml.value = draftData.html || '' // 如果有引用数据,重新添加到编辑器 if (currentQuoteData) { // 重新调用引用函数添加引用元素 onSubscribeQuote(currentQuoteData) } } } catch (error) { console.error('加载草稿失败:', error) } } else { // 没有草稿则清空编辑器内容,但保留引用 if (editorRef.value) { // 清空编辑器 editorRef.value.innerHTML = '' editorContent.value = '' editorHtml.value = '' // 如果有引用数据,重新添加到编辑器 if (currentQuoteData) { onSubscribeQuote(currentQuoteData) } } } }, 0) } // 监听会话变化,加载对应草稿 watch(indexName, loadDraft, { immediate: true }) // 组件挂载 onMounted(() => { bus.subscribe(EditorConst.Mention, onSubscribeMention) bus.subscribe(EditorConst.Quote, onSubscribeQuote) bus.subscribe(EditorConst.Edit, onSubscribeEdit) bus.subscribe(EditorConst.Clear, onSubscribeClear) // 为编辑器添加点击事件监听器,用于处理引用消息关闭等 if (editorRef.value) { editorRef.value.addEventListener('click', handleEditorClick); } // 点击外部隐藏mention document.addEventListener('click', (event) => { if (!editorRef.value?.contains(event.target)) { hideMentionList() } }) // 初始加载草稿 loadDraft() }) /** * 组件生命周期钩子 - 组件卸载前 * * onBeforeUnmount是Vue 3的生命周期钩子,在组件卸载前执行 * 在这里用于清理事件订阅,防止内存泄漏 * 使用bus.unsubscribe取消订阅之前通过bus.subscribe注册的事件处理函数 */ onBeforeUnmount(() => { // 取消订阅所有编辑器相关事件,防止内存泄漏和事件监听器残留 bus.unsubscribe(EditorConst.Mention, onSubscribeMention) bus.unsubscribe(EditorConst.Quote, onSubscribeQuote) bus.unsubscribe(EditorConst.Edit, onSubscribeEdit) bus.unsubscribe(EditorConst.Clear, onSubscribeClear) // 移除编辑器点击事件监听器 if (editorRef.value) { editorRef.value.removeEventListener('click', handleEditorClick); } // 清理DOM事件监听器 const editor = editorRef.value if (editor && handleDeleteQuote) { editor.removeEventListener('keydown', handleDeleteQuote) } }) /** * 表情选择事件处理函数 * * @param {Object} emoji - 选中的表情对象 * * 当用户从表情选择器中选择表情时触发 * 调用onEmoticonEvent插入表情到编辑器 * 然后关闭表情选择器面板 */ const onEmoticonSelect = (emoji) => { // 直接调用onEmoticonEvent处理表情 onEmoticonEvent(emoji) isShowEmoticon.value = false } /** * 代码块提交事件处理函数 * * @param {Object} data - 代码块数据 * * 当用户提交代码块时触发 * 通过emit向父组件发送code_event事件 * 传递代码块数据并关闭代码块面板 */ const onCodeSubmit = (data) => { emit('editor-event', { event: 'code_event', data, callBack: () => {} }) isShowCode.value = false } /** * 投票提交事件处理函数 * * @param {Object} data - 投票数据 * * 当用户提交投票时触发 * 通过emit向父组件发送vote_event事件 * 传递投票数据并关闭投票面板 */ const onVoteSubmit = (data) => { emit('editor-event', { event: 'vote_event', data, callBack: () => {} }) isShowVote.value = false } // 处理编辑器内部点击事件(用于关闭引用等) const handleEditorClick = (event) => { const closeButton = event.target.closest('.quote-close'); if (closeButton) { const quoteElement = event.target.closest('.editor-quote'); if (quoteElement) { quoteElement.remove(); quoteData.value = null; handleInput({ target: editorRef.value }); event.preventDefault(); event.stopPropagation(); } } }; </script> <template> <!-- 编辑器容器组件 使用el-container布局组件创建垂直布局的编辑器 包含工具栏和编辑区域两个主要部分 --> <section class="el-container editor"> <section class="el-container is-vertical"> <!-- 工具栏区域 使用el-header组件作为工具栏容器 包含各种编辑工具按钮 --> <header class="el-header toolbar bdr-t"> <div class="tools"> <!-- 表情选择器弹出框 使用n-popover组件创建悬浮的表情选择面板 通过trigger="click"设置点击触发 raw属性表示内容不会被包装在额外的div中 --> <n-popover placement="top-start" trigger="click" raw :show-arrow="false" :width="300" ref="emoticonRef" style="width: 500px; height: 250px; border-radius: 10px; overflow: hidden" > <!-- 触发器模板 点击此区域会显示表情选择器 --> <template #trigger> <div class="item pointer"> <n-icon size="18" class="icon" :component="SmilingFace" /> <p class="tip-title">表情符号</p> </div> </template> <!-- 表情选择器组件 通过on-select事件将选中的表情传递给onEmoticonEvent处理函数 --> <MeEditorEmoticon @on-select="onEmoticonEvent" /> </n-popover> <!-- 工具栏其他功能按钮 通过v-for循环渲染navs数组中定义的工具按钮 每个按钮包含图标和提示文本 通过v-show="nav.show"控制按钮的显示/隐藏 点击时执行nav.click定义的函数 --> <div class="item pointer" v-for="nav in navs" :key="nav.title" v-show="nav.show" @click="nav.click" > <n-icon size="18" class="icon" :component="nav.icon" /> <p class="tip-title">{{ nav.title }}</p> </div> </div> </header> <!-- 编辑器主体区域 使用el-main组件作为主要内容区域 --> <main class="el-main height100"> <!-- 可编辑区域 使用contenteditable="true"使div可编辑,这是HTML5的属性 ref="editorRef"用于获取DOM引用 placeholder属性用于显示占位文本 绑定多个事件处理函数处理用户交互 --> <div ref="editorRef" class="custom-editor" contenteditable="true" :placeholder="placeholder" @input="handleInput" @keydown="handleKeydown" @paste="handlePaste" @focus="handleFocus" @blur="handleBlur" ></div> <!-- @提及列表 当用户输入@时显示的用户列表 通过v-if="showMention"控制显示/隐藏 使用动态样式定位列表位置 --> <div v-if="showMention" class="mention-list py-5px" :style="{ top: mentionPosition.top + 'px', left: mentionPosition.left + 'px' }" > <n-virtual-list style="max-height: 140px;width: 163px;" :item-size="42" :items="mentionList"> <template #default="{ item }"> <div :key="item.key" class="cursor-pointer px-14px h-42px hover:bg-#EEE9F9" @mousedown.prevent="insertMention(item)"> <div class="flex items-center border-b-1px border-b-solid border-b-#F8F8F8 flex items-center h-full "> <img class="w-26px h-26px rounded-50% mr-11px" :src="item.avatar" alt=""> <span> {{ item.nickname }}</span> </div> </div> </template> </n-virtual-list> <ul> <!-- 提及列表项 循环渲染mentionList数组中的用户 通过:class="{ selected: index === selectedMentionIndex }"高亮当前选中项 @mousedown.prevent防止失去焦点 --> <!-- <li v-for="(member, index) in mentionList" :key="member.user_id" :class="{ selected: index === selectedMentionIndex }" @mousedown.prevent="insertMention(member)" > {{ member.nickname }} </li> --> </ul> </div> </main> </section> </section> <!-- 隐藏的文件上传表单 使用display: none隐藏表单 包含两个文件输入框,分别用于上传图片和其他文件 通过ref获取DOM引用 --> <form enctype="multipart/form-data" style="display: none"> <input type="file" ref="fileImageRef" accept="image/*" @change="onUploadFile" /> <input type="file" ref="uploadFileRef" @change="onUploadFile" /> </form> </template> <style lang="less" scoped> /** * 编辑器组件样式 * 使用CSS变量定义可复用的颜色值 * --tip-bg-color: 提示背景色 */ .editor { --tip-bg-color: rgb(241 241 241 / 90%); height: 100%; /** * 工具栏样式 * 固定高度的顶部工具栏 */ .toolbar { height: 38px; display: flex; /** * 工具按钮容器 * 使用flex布局排列工具按钮 */ .tools { height: 100%; flex: auto; display: flex; /** * 单个工具按钮样式 * 使用flex布局居中对齐内容 * 相对定位用于放置提示文本 */ .item { display: flex; align-items: center; justify-content: center; width: 35px; margin: 0 2px; position: relative; user-select: none; /* 防止文本被选中 */ /** * 提示文本样式 * 默认隐藏,悬停时显示 * 使用绝对定位在按钮下方显示 */ .tip-title { display: none; position: absolute; top: 40px; left: 0px; line-height: 26px; background-color: var(--tip-bg-color); color: var(--im-text-color); min-width: 20px; font-size: 12px; padding: 0 5px; border-radius: 2px; white-space: pre; /* 保留空白符 */ user-select: none; z-index: 999999999999; /* 确保提示显示在最上层 */ } /** * 悬停效果 * 当鼠标悬停在按钮上时显示提示文本 */ &:hover { .tip-title { display: block; } } } } } /** * 文件链接样式 * 使用:deep()选择器影响组件内部元素 * 显示文件链接的卡片式样式 */ :deep(.editor-file) { display: inline-block; padding: 5px 10px; margin: 5px 0; background-color: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 4px; color: #2196f3; text-decoration: none; position: relative; padding-right: 60px; /* 为文件大小信息留出空间 */ max-width: 100%; box-sizing: border-box; overflow: hidden; text-overflow: ellipsis; /* 文本溢出时显示省略号 */ white-space: nowrap; /* 防止文本换行 */ &::after { content: attr(data-size); position: absolute; right: 10px; color: #757575; font-size: 12px; } &:hover { background-color: #e3f2fd; } } // 表情符号样式 /** * 表情符号样式 * 使用:deep()选择器影响组件内部元素 * 设置表情符号的大小和对齐方式 */ :deep(.editor-emoji) { display: inline-block; width: 24px; height: 24px; vertical-align: middle; /* 垂直居中对齐 */ margin: 0 2px; } /** * 引用消息样式 * 使用:deep()选择器影响组件内部元素 * 创建带有左侧边框的引用卡片 * 使用CSS变量适配不同主题 */ :deep(.editor-quote) { margin-bottom: 8px; padding: 8px 12px; background-color: var(--im-message-left-bg-color, #f5f5f5); border-left: 3px solid var(--im-primary-color, #409eff); border-radius: 4px; font-size: 13px; position: relative; display: flex; justify-content: space-between; align-items: flex-start; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* 轻微阴影效果 */ cursor: pointer; /* 添加指针样式 */ user-select: none; /* 防止文本选择 */ transition: background-color 0.2s ease; /* 平滑过渡效果 */ /** * 引用卡片悬停效果 * 改变背景色提供视觉反馈 */ &:hover { background-color: var(--im-message-left-bg-hover-color, #eaeaea); } /** * 引用内容容器 * 使用flex: 1占据剩余空间 * 处理内容溢出 */ .quote-content-wrapper { flex: 1; overflow: hidden; } /** * 引用标题样式 * 使用主题色显示引用来源 */ .quote-title { color: var(--im-primary-color, #409eff); margin-bottom: 4px; font-weight: 500; } /** * 引用内容样式 * 限制显示行数,超出部分显示省略号 * 使用-webkit-line-clamp实现多行文本截断 */ .quote-content { color: var(--im-text-color, #333); word-break: break-all; /* 在任意字符间断行 */ white-space: normal; /* 允许正常换行 */ overflow: hidden; text-overflow: ellipsis; /* 文本溢出显示省略号 */ display: -webkit-box; -webkit-line-clamp: 2; /* 限制显示2行 */ -webkit-box-orient: vertical; } /** * 引用图片样式 * 限制图片大小,添加圆角 */ .quote-image img { max-width: 100px; max-height: 60px; border-radius: 3px; pointer-events: none; /* 防止图片被拖拽 */ } /** * 引用关闭按钮样式 * 圆形按钮,悬停时改变背景色和文字颜色 */ .quote-close { width: 18px; height: 18px; line-height: 16px; text-align: center; border-radius: 50%; background-color: rgba(0, 0, 0, 0.1); color: #666; cursor: pointer; font-size: 16px; margin-left: 8px; user-select: none; &:hover { background-color: rgba(0, 0, 0, 0.2); color: #333; } } } .custom-editor { width: 100%; height: 100%; padding: 8px; border: none; outline: none; resize: none; font-size: 14px; line-height: 1.5; color: #333; background: transparent; overflow-y: auto; /** * 自定义滚动条样式 - 轨道 * 使用::-webkit-scrollbar伪元素自定义滚动条轨道 * 设置宽度和高度为3px,背景色为透明 */ &::-webkit-scrollbar { width: 3px; height: 3px; background-color: unset; } /** * 自定义滚动条样式 - 滑块 * 使用::-webkit-scrollbar-thumb伪元素自定义滚动条滑块 * 设置圆角和透明背景色 */ &::-webkit-scrollbar-thumb { border-radius: 3px; background-color: transparent; } /** * 滚动条悬停效果 * 当鼠标悬停在编辑器上时,显示滚动条滑块 * 使用CSS变量适配不同主题的滚动条颜色 */ &:hover { &::-webkit-scrollbar-thumb { background-color: var(--im-scrollbar-thumb); } } } /** * 编辑器占位符样式 * 使用:empty::before伪元素在编辑器为空时显示占位文本 * content: attr(placeholder)获取placeholder属性的值作为显示内容 */ .custom-editor:empty::before { content: attr(placeholder); color: #999; pointer-events: none; /* 防止占位符文本接收鼠标事件 */ font-family: PingFang SC, Microsoft YaHei, 'Alibaba PuHuiTi 2.0 45' !important; } /** * 编辑器聚焦样式 * 移除默认的聚焦轮廓 */ .custom-editor:focus { outline: none; } /** * @提及样式 * 为@提及的用户名添加特殊样式 * 使用蓝色背景和文字颜色突出显示 */ .mention { color: #1890ff; background-color: #e6f7ff; padding: 2px 4px; border-radius: 3px; text-decoration: none; cursor: pointer; } /** * @提及悬停效果 * 当鼠标悬停在@提及上时改变背景色 */ .mention:hover { background-color: #bae7ff; } /** * 图片样式 * 限制编辑器中插入的图片大小 * 添加圆角和鼠标指针样式 */ .editor-image { max-width: 300px; max-height: 200px; border-radius: 3px; background-color: #48484d; margin: 0px 2px; cursor: pointer; object-fit: contain; /* 保持原始比例 */ display: inline-block; /* 确保图片正确显示 */ } /** * 表情样式 * 设置表情图片的大小和对齐方式 */ .editor-emoji { width: 20px; height: 20px; vertical-align: middle; margin: 0 2px; } /** * @提及列表样式 * 定位和样式化@提及的下拉列表 * 使用绝对定位、白色背景和阴影效果 */ .mention-list { position: absolute; background-color: white; border: 1px solid #eee; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); z-index: 1000; } /* 引用卡片样式 */ .quote-card { background: #f5f5f5; border-left: 3px solid #1890ff; padding: 8px 12px; margin: 8px 0; border-radius: 4px; position: relative; } /** * 引用内容样式 * 设置较小的字体大小 */ .quote-content { font-size: 12px; } /** * 引用标题样式 * 使用粗体和主题蓝色 * 添加底部间距 */ .quote-title { font-weight: bold; color: #1890ff; margin-bottom: 4px; } /** * 引用文本样式 * 使用灰色文本和适当的行高 */ .quote-text { color: #666; line-height: 1.4; } /** * 引用关闭按钮样式 * 绝对定位在右上角 * 移除默认按钮样式,添加鼠标指针 */ .quote-close { position: absolute; top: 4px; right: 4px; background: none; border: none; cursor: pointer; color: #999; font-size: 12px; } /** * 编辑提示样式 * 使用橙色背景和边框创建警示效果 * 添加内边距、圆角和适当的字体大小 * 使用flex布局对齐内容 */ .edit-tip { background: #fff7e6; border: 1px solid #ffd591; padding: 6px 12px; margin-bottom: 8px; border-radius: 4px; font-size: 12px; color: #d46b08; display: flex; align-items: center; gap: 8px; } /** * 编辑提示按钮样式 * 移除默认按钮样式,添加鼠标指针 * 使用margin-left: auto将按钮推到右侧 */ .edit-tip button { background: none; border: none; cursor: pointer; color: #d46b08; margin-left: auto; } } /** * 暗色模式样式调整 * 使用HTML属性选择器检测暗色主题 * 重新定义暗色模式下的CSS变量 */ html[theme-mode='dark'] { .editor { --tip-bg-color: #48484d; } } </style>