chat-pc/src/components/editor/CustomEditor.vue
Phoenix 047cea20b9 refactor(editor): 将工具栏配置从reactive改为ref并调整表情组件样式
将CustomEditor中的navs从reactive改为ref以提高性能
调整MeEditorEmoticon的样式,包括间距改为内边距、添加悬停背景色,并移除表情缩放效果
2025-06-09 11:44:57 +08:00

1860 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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'
import {IosSend} from '@vicons/ionicons4'
const props = defineProps({
vote: {
type: Boolean,
default: false
},
members: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: 'Enter-发送消息 [Ctrl+Enter/Shift+Enter]-换行'
}
})
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 = ref([
{
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
})
// 处理输入事件 - 优化版本减少DOM操作
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 属性添加到文本内容中
if (emojiImages.length > 0) {
emojiImages.forEach(emoji => {
const altText = emoji.getAttribute('alt')
if (altText) {
textContent += altText
}
})
}
// 更新逻辑文本内容
editorContent.value = textContent
// 检查是否需要清空编辑器以显示placeholder
const isEmpty = textContent.trim() === '' &&
!target.querySelector('img, .editor-file, .mention')
if (isEmpty && target.innerHTML !== '') {
target.innerHTML = ''
}
// 更新HTML内容
editorHtml.value = target.innerHTML || ''
// 后续操作
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) {
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);
uploadImg(form).then(({ code, data, message }) => {
if (code == 0) {
const editorImages = editorRef.value.querySelectorAll('img.editor-image');
const lastImage = editorImages[editorImages.length - 1];
if (lastImage && lastImage.src === tempUrl) {
lastImage.src = data.ori_url;
handleInput({ target: editorRef.value });
}
} else {
window['$message'].error(message);
}
});
};
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)
// 确保选中项可见 - 向上滚动
nextTick(() => {
const mentionList = document.querySelector('.mention-list ul')
const selectedItem = mentionList?.children[selectedMentionIndex.value]
if (mentionList && selectedItem && selectedItem.offsetTop < mentionList.scrollTop) {
mentionList.scrollTop = selectedItem.offsetTop
}
})
break
case 'ArrowDown':
event.preventDefault()
selectedMentionIndex.value = Math.min(mentionList.value.length - 1, selectedMentionIndex.value + 1)
// 确保选中项可见 - 向下滚动
nextTick(() => {
const mentionList = document.querySelector('.mention-list ul')
const selectedItem = mentionList?.children[selectedMentionIndex.value]
if (mentionList && selectedItem) {
const itemBottom = selectedItem.offsetTop + selectedItem.offsetHeight
const listBottom = mentionList.scrollTop + mentionList.clientHeight
if (itemBottom > listBottom) {
mentionList.scrollTop = itemBottom - mentionList.clientHeight
}
}
})
break
case 'Enter':
case 'Tab':
event.preventDefault()
const selectedMember = mentionList.value[selectedMentionIndex.value]
if (selectedMember) {
insertMention(selectedMember)
}
break
case 'Escape':
hideMentionList()
break
}
return
}
// 处理Ctrl+Enter或Shift+Enter换行
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey || event.shiftKey)) {
// 手动插入换行符
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const br = document.createElement('br')
range.deleteContents()
range.insertNode(br)
// 在换行符后添加一个空文本节点,并将光标移动到这个节点
const textNode = document.createTextNode('')
range.setStartAfter(br)
range.insertNode(textNode)
range.setStartAfter(textNode)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
// 触发输入事件更新编辑器内容
handleInput({ target: editorRef.value })
}
// 阻止默认行为,防止触发表单提交
event.preventDefault()
return
}
// 处理Enter键发送消息只有在没有按Ctrl/Cmd/Shift时才发送
if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
event.preventDefault()
// 检查引用元素是否存在,如果不存在但 quoteData 有值,则清除 quoteData
const editor = editorRef.value
const quoteElement = editor?.querySelector('.editor-quote')
if (!quoteElement && quoteData.value) {
quoteData.value = null
}
// 解析编辑器内容并发送消息
sendMessage()
}
}
// 发送消息 - 优化版本,移除不必要的日志,简化逻辑
const sendMessage = () => {
// 解析编辑器内容
const messageData = parseEditorContent()
// 检查是否有内容可发送
if (messageData.items.length === 0 ||
(messageData.items.length === 1 &&
messageData.items[0].type === 1 &&
!messageData.items[0].content.trim())) {
return // 没有内容,不发送
}
// 处理不同类型的消息
messageData.items.forEach(item => {
// 处理文本内容
if (item.type === 1 && item.content.trim()) {
const data = {
items: [{
content: item.content,
type: 1
}],
mentionUids: messageData.mentionUids,
mentions: [],
quoteId: messageData.quoteId,
}
emit(
'editor-event',
emitCall('text_event', data)
)
} else if (item.type === 3) { // 图片消息
const data = {
height: 0,
width: 0,
size: 10000,
url: item.content,
}
emit(
'editor-event',
emitCall('image_event', data)
)
} else if (item.type === 4) { // 文件消息
// 文件消息处理逻辑
}
})
// 发送后清空编辑器
clearEditor()
}
// 解析编辑器内容
const parseEditorContent = () => {
const items = []
const mentionUids = []
// 解析HTML内容
const tempDiv = document.createElement('div')
tempDiv.innerHTML = editorHtml.value
// 检查是否有引用元素
const quoteElements = tempDiv.querySelectorAll('.editor-quote')
const hasQuote = quoteElements.length > 0 && quoteData.value
const quoteId = hasQuote ? quoteData.value.id || '' : ''
// 移除引用元素,避免重复处理
quoteElements.forEach(quote => quote.remove())
let textContent = ''
// 处理文本节点和元素节点
const processNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
// 文本节点直接添加内容
textContent += node.textContent
return
}
if (node.nodeType !== Node.ELEMENT_NODE) 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') {
// 处理图片
processImage(node)
} else if (node.classList.contains('emoji')) {
// 处理emoji元素
textContent += node.getAttribute('alt') || node.textContent
} else if (node.classList.contains('editor-file')) {
// 处理文件
processFile(node)
} else if (node.childNodes.length) {
// 处理有子节点的元素
Array.from(node.childNodes).forEach(processNode)
} else {
// 其他元素,添加文本内容
textContent += node.textContent
}
}
// 处理图片元素
const processImage = (node) => {
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')
// 如果有累积的文本内容先添加到items
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
})
}
}
// 处理文件元素
const processFile = (node) => {
const fileUrl = node.getAttribute('data-url')
const fileName = node.getAttribute('data-name')
const fileSize = node.getAttribute('data-size')
// 如果有累积的文本内容先添加到items
if (textContent.trim()) {
items.push({
type: 1,
content: textContent.trim()
})
textContent = ''
}
if (fileUrl && fileName) {
items.push({
type: 4, // 使用数字类型保持一致性
content: fileUrl,
name: fileName,
size: node.getAttribute('data-size-raw') || fileSize || 0
})
}
}
// 处理所有顶级节点
Array.from(tempDiv.childNodes).forEach(processNode)
// 处理剩余的文本内容
if (textContent.trim()) {
items.push({
type: 1,
content: textContent.trim()
})
}
// 构建完整的消息数据结构
return {
items: items.length > 0 ? items : [{ type: 1, content: '' }],
mentionUids,
quoteId
}
}
// 清空编辑器
const clearEditor = () => {
// 一次性清空所有编辑器相关状态
editorContent.value = ''
editorHtml.value = ''
quoteData.value = null
// 清空DOM内容
if (editorRef.value) {
editorRef.value.innerHTML = ''
// 立即设置焦点,提高响应速度
nextTick(() => editorRef.value.focus())
}
// 隐藏@提及列表
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 = '150px'
img.style.maxWidth = '150px'
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'
}
}
//工具栏中选完图片直接发送
const onUploadSendImg=async (eventFile)=>{
for (const file of eventFile.target.files) {
const form = new FormData();
form.append('file', file);
form.append("source", "fonchain-chat");
const res=await uploadImg(form)
if(res.status===0){
const data={
height:0,
width:0,
size:10000,
url:res.data.ori_url,
}
emit(
'editor-event',
emitCall(
'image_event',
data
)
)
}
}
}
async function onUploadFile(e) {
const file = e.target.files[0]
if (!file) return
// 清空input允许再次选择相同文件
e.target.value = null
if (file.type.indexOf('image/') === 0) {
// 处理图片文件 - 立即显示临时消息,然后上传
emit('editor-event', emitCall('image_event', file))
return
}
if (file.type.indexOf('video/') === 0) {
// 处理视频文件
emit('editor-event', emitCall('video_event', file))
} else {
// 处理其他类型文件
emit('editor-event', emitCall('file_event', file))
}
}
// 表情选择事件
const onEmoticonEvent = (emoji) => {
// 关闭表情面板
emoticonRef.value?.setShow(false)
// 处理不同类型的表情
switch (emoji.type) {
case 'text':
case 'emoji':
// 文本表情和系统表情都使用相同的处理方式
insertTextEmoji(emoji.value)
break
case 'image':
// 图片表情
insertImageEmoji(emoji.img, emoji.value)
break
case 1: // 兼容旧版表情格式
emoji.img ? insertImageEmoji(emoji.img, emoji.value) : insertTextEmoji(emoji.value)
break
default:
// 发送整个表情包
emit('editor-event', {
event: 'emoticon_event',
data: emoji.value || emoji.id
})
break
}
}
// 插入文本表情
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) => {
// 检查点击的是否是关闭按钮或其内部元素
const closeButton = e.target.classList?.contains('quote-close') ? e.target : e.target.closest('.quote-close')
// 阻止事件冒泡,避免触发编辑器的其他点击事件
e.stopPropagation()
if (closeButton) {
// 移除引用元素并清除引用数据
quoteElement.remove()
quoteData.value = null
// 只更新编辑器内容变量不触发handleInput避免保存草稿
editorContent.value = editor.textContent || ''
editorHtml.value = editor.innerHTML || ''
// 使用nextTick确保DOM更新后再设置焦点
nextTick(() => editor.focus())
} 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') return;
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;
}
// 获取引用元素在子节点中的索引
const quoteIndex = Array.from(editor.childNodes).indexOf(quoteElement);
// 检查光标是否在引用卡片前面Backspace或后面Delete
const isBeforeQuote = e.key === 'Backspace' &&
range.collapsed &&
range.startContainer === editor &&
quoteIndex === range.startOffset;
const isAfterQuote = e.key === 'Delete' &&
range.collapsed &&
range.startContainer === editor &&
quoteIndex === range.startOffset - 1;
if (isBeforeQuote || isAfterQuote) {
// 阻止默认删除行为
e.preventDefault();
// 移除引用元素并清除引用数据
quoteElement.remove();
quoteData.value = null;
// 更新编辑器内容
handleInput({ target: editor });
}
};
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 || !editorRef.value) return
// 获取编辑器内容,但排除引用元素
// 使用DocumentFragment进行高效的DOM操作
const fragment = document.createDocumentFragment()
const tempDiv = document.createElement('div')
tempDiv.innerHTML = editorRef.value.innerHTML
fragment.appendChild(tempDiv)
// 从临时DOM中移除引用元素
const quoteElements = tempDiv.querySelectorAll('.editor-quote')
quoteElements.forEach(quote => quote.remove())
// 获取不包含引用的内容
const contentToSave = tempDiv.textContent || ''
const htmlToSave = tempDiv.innerHTML || ''
// 检查是否有实际内容(不包括引用)
const hasContent = contentToSave.trim().length > 0 ||
htmlToSave.includes('<img') ||
htmlToSave.includes('editor-file')
// 根据内容状态保存或删除草稿
if (hasContent) {
// 保存草稿到store不包括引用数据
editorDraftStore.items[indexName.value] = JSON.stringify({
content: contentToSave,
html: htmlToSave
})
} else {
// 编辑器为空时删除对应草稿
delete editorDraftStore.items[indexName.value]
}
}
// 加载草稿
const loadDraft = () => {
if (!indexName.value) return
// 使用nextTick确保DOM已渲染更可预测且性能更好
nextTick(() => {
// 保存当前引用数据的临时副本
const currentQuoteData = quoteData.value
// 清除当前引用数据,避免重复添加
quoteData.value = null
// 如果编辑器引用不存在,直接返回
if (!editorRef.value) return
// 先清空编辑器内容
editorRef.value.innerHTML = ''
editorContent.value = ''
editorHtml.value = ''
// 获取草稿数据
const draft = editorDraftStore.items[indexName.value]
// 如果有草稿,恢复草稿内容
if (draft) {
try {
const draftData = JSON.parse(draft)
// 恢复草稿内容
editorRef.value.innerHTML = draftData.html || ''
editorContent.value = draftData.content || ''
editorHtml.value = draftData.html || ''
} catch (error) {
console.warn('加载草稿失败,使用空内容', error)
}
}
// 如果有引用数据,重新添加到编辑器(无论是否有草稿)
if (currentQuoteData) {
onSubscribeQuote(currentQuoteData)
}
})
}
// 监听会话变化,加载对应草稿
watch(indexName, loadDraft, { immediate: true })
// 处理点击文档事件,隐藏@提及列表
const handleDocumentClick = (event) => {
if (!editorRef.value?.contains(event.target)) {
hideMentionList()
}
}
// 组件挂载
onMounted(() => {
// 订阅所有编辑器相关事件
const subscriptions = [
[EditorConst.Mention, onSubscribeMention],
[EditorConst.Quote, onSubscribeQuote],
[EditorConst.Edit, onSubscribeEdit],
[EditorConst.Clear, onSubscribeClear]
]
// 批量订阅事件
subscriptions.forEach(([event, handler]) => {
bus.subscribe(event, handler)
})
// 为编辑器添加点击事件监听器
editorRef.value?.addEventListener('click', handleEditorClick)
// 点击外部隐藏mention - 使用命名函数便于清理
document.addEventListener('click', handleDocumentClick)
// 初始加载草稿
loadDraft()
})
/**
* 组件生命周期钩子 - 组件卸载前
* 清理所有事件订阅和监听器,防止内存泄漏
*/
onBeforeUnmount(() => {
// 取消订阅所有编辑器相关事件
const subscriptions = [
[EditorConst.Mention, onSubscribeMention],
[EditorConst.Quote, onSubscribeQuote],
[EditorConst.Edit, onSubscribeEdit],
[EditorConst.Clear, onSubscribeClear]
]
// 批量取消订阅
subscriptions.forEach(([event, handler]) => {
bus.unsubscribe(event, handler)
})
// 移除编辑器点击事件监听器
editorRef.value?.removeEventListener('click', handleEditorClick)
// 移除文档点击事件监听器
document.removeEventListener('click', handleDocumentClick)
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 pr-30px">
<!--
表情选择器弹出框
使用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>
<n-button class="w-80px h-30px ml-auto" type="primary">
<template #icon>
<n-icon>
<IosSend />
</n-icon>
</template>
发送
</n-button>
</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' }"
>
<ul class="max-h-140px w-163px overflow-auto hide-scrollbar">
<li
v-for="(member, index) in mentionList"
:key="member.user_id || member.id"
class="cursor-pointer px-14px h-42px"
:class="{ 'bg-#EEE9F9': index === selectedMentionIndex }"
@mousedown.prevent="insertMention(member)"
@mouseover="selectedMentionIndex = index"
>
<div class="flex items-center border-b-1px border-b-solid border-b-#F8F8F8 h-full">
<img class="w-26px h-26px rounded-50% mr-11px" :src="member.avatar" alt="">
<span>{{ member.nickname }}</span>
</div>
</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="onUploadSendImg" />
<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: 40px;
flex: auto;
display: flex;
align-items: center;
/**
* 单个工具按钮样式
* 使用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(.editor-file) {
display: inline-block;
padding: 5px 10px;
margin: 5px 0;
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 4px;
color: #462AA0;
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(.editor-emoji) {
display: inline-block;
width: 24px;
height: 24px;
vertical-align: middle; /* 垂直居中对齐 */
margin: 0 2px;
}
: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;
/* 添加placeholder样式 */
&:empty:before {
content: attr(placeholder);
color: #999;
pointer-events: none;
}
/**
* 自定义滚动条样式 - 轨道
* 使用::-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;
}
}
/**
* 隐藏滚动条样式
* 保留滚动功能但隐藏滚动条的视觉显示
*/
.hide-scrollbar {
/* Chrome, Safari, Edge */
&::-webkit-scrollbar {
width: 0;
display: none;
}
/* Firefox */
scrollbar-width: none;
/* IE */
-ms-overflow-style: none;
}
</style>