chat-pc/src/components/editor/CustomEditor.vue

1670 lines
45 KiB
Vue
Raw Normal View History

2025-06-05 08:21:39 +00:00
<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 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)
// 确保编辑器内容不为空(文本、图片、文件或表情)
if (editorContent.value.trim() ||
editorHtml.value.includes('<img') ||
editorHtml.value.includes('editor-file') ||
editorHtml.value.includes('editor-emoji')) {
sendMessage()
}
}
// Ctrl+Enter换行由WangEditor的onKeyDown处理这里不需要额外处理
}
// 发送消息
const sendMessage = () => {
console.log('发送消息');
// 检查编辑器是否有内容:文本、图片、文件或表情
if (!editorContent.value.trim() &&
!editorHtml.value.includes('<img') &&
!editorHtml.value.includes('editor-file') &&
!editorHtml.value.includes('editor-emoji')) {
return
}
console.log('发送消息1');
const messageData = parseEditorContent()
if (editingMessage.value) {
// 编辑消息
emit('editor-event', {
event: 'edit_message',
data: {
...messageData,
msg_id: editingMessage.value.msg_id
},
callBack: (success) => {
if (success) {
clearEditor()
editingMessage.value = null
}
}
})
} else {
// 发送新消息
const eventType = messageData.items.length > 1 ? 'mixed_event' :
messageData.items[0].type === 1 ? 'text_event' :
messageData.items[0].type + '_event'
console.log('发送消息2',eventType);
emit('editor-event', {
event: eventType,
data: messageData,
callBack: (success) => {
if (success) {
clearEditor()
}
}
})
}
}
// 解析编辑器内容
const parseEditorContent = () => {
const items = []
const mentionUids = []
// 解析HTML内容
const tempDiv = document.createElement('div')
tempDiv.innerHTML = editorHtml.value
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('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('width') || ''
const 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}` : '')
})
}
} 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()
})
}
return {
items: items.length > 0 ? items : [{ type: 1, content: '' }],
mentionUids,
quoteId: quoteData.value?.msg_id || 0
}
}
// 清空编辑器
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 = (url, width, height) => {
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
const img = document.createElement('img')
img.src = url
img.style.maxWidth = '200px'
img.style.maxHeight = '200px'
if (width) img.setAttribute('width', width)
if (height) img.setAttribute('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 insertFile = (url, fileName, fileSize) => {
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
// 创建文件链接元素
const fileLink = document.createElement('a')
fileLink.href = url
fileLink.target = '_blank'
fileLink.className = 'editor-file'
fileLink.textContent = fileName
fileLink.setAttribute('data-size', formatFileSize(fileSize))
fileLink.setAttribute('data-url', url) // 添加URL属性用于解析
fileLink.setAttribute('data-name', fileName) // 添加文件名属性用于解析
fileLink.setAttribute('data-size-raw', fileSize) // 添加原始文件大小属性用于解析
range.deleteContents()
range.insertNode(fileLink)
range.setStartAfter(fileLink)
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 file = e.target.files[0]
e.target.value = null // 清空input允许再次选择相同文件
console.log("文件类型"+file.type)
if (file.type.indexOf('image/') === 0) {
console.log("进入图片")
// 处理图片文件 - 立即显示临时消息,然后上传
let fn = emitCall('image_event', file, () => {})
emit('editor-event', fn)
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()
// 插入@提及
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)
}
// 添加关闭按钮点击事件
const closeBtn = quoteElement.querySelector('.quote-close')
if (closeBtn) {
closeBtn.addEventListener('click', () => {
// 移除引用元素
quoteElement.remove()
// 清除引用数据
quoteData.value = null
// 不触发handleInput避免保存草稿
// 只更新编辑器内容变量
editorContent.value = editor.textContent || ''
editorHtml.value = editor.innerHTML || ''
// 确保编辑器获得焦点
setTimeout(() => {
editor.focus()
}, 0)
})
}
// 注意不调用saveDraft(),确保引用内容不会被保存到草稿中
// 添加点击整个引用卡片的事件
quoteElement.addEventListener('click', (e) => {
// 如果不是点击关闭按钮,则设置光标到引用卡片后面
if (!e.target.classList.contains('quote-close')) {
const selection = window.getSelection()
const range = document.createRange()
range.setStartAfter(quoteElement)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
// 确保编辑器获得焦点
editor.focus()
}
})
// 监听键盘事件,处理删除操作
// 监听键盘事件,处理删除操作
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)
// 点击外部隐藏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)
// 清理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
}
</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: 100px;
max-height: 100px;
border-radius: 3px;
background-color: #48484d;
margin: 0px 2px;
cursor: pointer;
}
/**
* 表情样式
* 设置表情图片的大小和对齐方式
*/
.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>