chat-pc/src/components/editor/CustomEditor.vue
Phoenix 3ec981ea7f fix: 修复文件上传和编辑器相关问题
- 启用vueDevTools插件用于开发调试
- 移除调试用的console.error/log语句
- 修复文件扩展名获取可能导致的错误
- 优化文件上传逻辑,添加path字段
- 重构编辑器图片上传处理,支持直接发送
- 调整编辑器样式颜色
2025-06-06 16:57:02 +08:00

1884 lines
52 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 = 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) {
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) {
// 如果选中项在可视区域上方,滚动到选中项
if (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()
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, 'Shift:', event.shiftKey);
// 处理Ctrl+Enter或Shift+Enter换行
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey || event.shiftKey)) {
console.log('Ctrl+Enter或Shift+Enter换行');
// 不阻止默认行为,允许插入换行符
// 手动插入换行符
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) {
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 ? {
id: quoteData.value.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){
}
clearEditor()
})
}
// 解析编辑器内容
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 = {
id: quoteData.value.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'
}
}
//工具栏中选完图片直接发送
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) {
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()
// 如果编辑器为空或者光标不在编辑器内,将光标移动到编辑器末尾
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 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>