1078 lines
26 KiB
Vue
1078 lines
26 KiB
Vue
|
<script setup>
|
||
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, markRaw } 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 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
|
||
|
editorContent.value = target.textContent || ''
|
||
|
editorHtml.value = target.innerHTML || ''
|
||
|
|
||
|
// 检查@mention
|
||
|
checkMention(target)
|
||
|
|
||
|
// 发送输入事件
|
||
|
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)
|
||
|
|
||
|
if (atIndex !== -1) {
|
||
|
// 创建mention元素
|
||
|
const mentionSpan = document.createElement('span')
|
||
|
mentionSpan.className = 'mention'
|
||
|
mentionSpan.setAttribute('data-user-id', member.user_id)
|
||
|
mentionSpan.textContent = `@${member.nickname}`
|
||
|
mentionSpan.contentEditable = 'false'
|
||
|
|
||
|
// 替换文本
|
||
|
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)
|
||
|
|
||
|
// 设置光标位置
|
||
|
const newRange = document.createRange()
|
||
|
newRange.setStartAfter(mentionSpan)
|
||
|
newRange.collapse(true)
|
||
|
selection.removeAllRanges()
|
||
|
selection.addRange(newRange)
|
||
|
}
|
||
|
|
||
|
hideMentionList()
|
||
|
editorRef.value.focus()
|
||
|
}
|
||
|
|
||
|
// 处理粘贴事件
|
||
|
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
|
||
|
}
|
||
|
|
||
|
// 处理Enter键发送消息
|
||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||
|
event.preventDefault()
|
||
|
// 确保编辑器内容不为空
|
||
|
if (editorContent.value.trim() || editorHtml.value.includes('<img') || editorHtml.value.includes('editor-file')) {
|
||
|
sendMessage()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// 发送消息
|
||
|
const sendMessage = () => {
|
||
|
// 检查编辑器是否有内容:文本、图片
|
||
|
if (!editorContent.value.trim() && !editorHtml.value.includes('<img') && !editorHtml.value.includes('editor-file')) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
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 === 'text' ? 'text_event' :
|
||
|
messageData.items[0].type + '_event'
|
||
|
|
||
|
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') || ''
|
||
|
|
||
|
if (textContent.trim()) {
|
||
|
items.push({
|
||
|
type: 'text',
|
||
|
content: textContent.trim()
|
||
|
})
|
||
|
textContent = ''
|
||
|
}
|
||
|
|
||
|
items.push({
|
||
|
type: 'image',
|
||
|
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: 'text',
|
||
|
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: 'text',
|
||
|
content: textContent.trim()
|
||
|
})
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
items: items.length > 0 ? items : [{ type: 'text', content: '' }],
|
||
|
mentionUids,
|
||
|
quoteId: quoteData.value?.msg_id || 0
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// 清空编辑器
|
||
|
const clearEditor = () => {
|
||
|
editorContent.value = ''
|
||
|
editorHtml.value = ''
|
||
|
if (editorRef.value) {
|
||
|
editorRef.value.innerHTML = ''
|
||
|
}
|
||
|
quoteData.value = null
|
||
|
hideMentionList()
|
||
|
}
|
||
|
|
||
|
|
||
|
|
||
|
// 插入图片
|
||
|
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'
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// 文件上传处理
|
||
|
const onUploadFile = async (event) => {
|
||
|
const file = event.target.files[0]
|
||
|
if (!file) return
|
||
|
|
||
|
try {
|
||
|
const formData = new FormData()
|
||
|
formData.append('file', file)
|
||
|
formData.append('source', 'fonchain-chat')
|
||
|
|
||
|
if (file.type.startsWith('image/')) {
|
||
|
// 图片上传
|
||
|
const image = new Image()
|
||
|
image.src = URL.createObjectURL(file)
|
||
|
image.onload = async () => {
|
||
|
formData.append('urlParam', `width=${image.width}&height=${image.height}`)
|
||
|
const { code, data } = await uploadImg(formData)
|
||
|
if (code === 0) {
|
||
|
insertImage(data.ori_url, image.width, image.height)
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
// 文件上传
|
||
|
const { code, data } = await uploadImg(formData)
|
||
|
if (code === 0) {
|
||
|
// 直接发送文件消息,而不是插入到编辑器中
|
||
|
emit('editor-event', {
|
||
|
event: 'file_event',
|
||
|
data: {
|
||
|
items: [{
|
||
|
type: 'file',
|
||
|
content: data.ori_url,
|
||
|
name: file.name,
|
||
|
size: file.size
|
||
|
}],
|
||
|
mentionUids: [],
|
||
|
quoteId: quoteData.value?.msg_id || 0
|
||
|
},
|
||
|
callBack: (success) => {
|
||
|
if (success) {
|
||
|
// 如果需要,可以在这里清空编辑器
|
||
|
// clearEditor()
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.error('文件上传失败:', error)
|
||
|
}
|
||
|
|
||
|
// 清空文件输入
|
||
|
event.target.value = ''
|
||
|
}
|
||
|
|
||
|
// 表情选择事件
|
||
|
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 onImageUpload = async (event) => {
|
||
|
const files = event.target.files
|
||
|
if (!files || files.length === 0) return
|
||
|
|
||
|
const file = files[0]
|
||
|
if (!file.type.startsWith('image/')) {
|
||
|
window['$message'].warning('请选择图片文件')
|
||
|
return
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
const formData = new FormData()
|
||
|
formData.append('file', file)
|
||
|
|
||
|
const response = await ServeUploadImage(formData)
|
||
|
if (response.code === 200) {
|
||
|
// 获取图片尺寸
|
||
|
const img = new Image()
|
||
|
img.onload = () => {
|
||
|
insertImage(response.data.url, img.width, img.height)
|
||
|
}
|
||
|
img.src = response.data.url
|
||
|
}
|
||
|
} catch (error) {
|
||
|
window['$message'].error('图片上传失败')
|
||
|
}
|
||
|
|
||
|
event.target.value = ''
|
||
|
}
|
||
|
|
||
|
// 文件上传
|
||
|
const onFileUpload = (event) => {
|
||
|
const files = event.target.files
|
||
|
if (!files || files.length === 0) return
|
||
|
|
||
|
const file = files[0]
|
||
|
emit('editor-event', {
|
||
|
event: 'file_event',
|
||
|
data: file
|
||
|
})
|
||
|
|
||
|
event.target.value = ''
|
||
|
}
|
||
|
|
||
|
// 事件监听
|
||
|
const onSubscribeMention = (data) => {
|
||
|
insertMention(data)
|
||
|
}
|
||
|
|
||
|
const onSubscribeQuote = (data) => {
|
||
|
quoteData.value = data
|
||
|
}
|
||
|
|
||
|
const onSubscribeEdit = (data) => {
|
||
|
editingMessage.value = data
|
||
|
clearEditor()
|
||
|
|
||
|
// 插入编辑内容
|
||
|
if (data.content) {
|
||
|
editorRef.value.innerHTML = data.content
|
||
|
editorContent.value = data.content
|
||
|
editorHtml.value = data.content
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// 组件挂载
|
||
|
onMounted(() => {
|
||
|
bus.subscribe('mention', onSubscribeMention)
|
||
|
bus.subscribe('quote', onSubscribeQuote)
|
||
|
bus.subscribe('edit', onSubscribeEdit)
|
||
|
|
||
|
// 点击外部隐藏mention
|
||
|
document.addEventListener('click', (event) => {
|
||
|
if (!editorRef.value?.contains(event.target)) {
|
||
|
hideMentionList()
|
||
|
}
|
||
|
})
|
||
|
})
|
||
|
|
||
|
// 组件卸载
|
||
|
onBeforeUnmount(() => {
|
||
|
bus.unsubscribe('mention', onSubscribeMention)
|
||
|
bus.unsubscribe('quote', onSubscribeQuote)
|
||
|
bus.unsubscribe('edit', onSubscribeEdit)
|
||
|
})
|
||
|
|
||
|
// 表情选择
|
||
|
const onEmoticonSelect = (emoji) => {
|
||
|
// 直接调用onEmoticonEvent处理表情
|
||
|
onEmoticonEvent(emoji)
|
||
|
isShowEmoticon.value = false
|
||
|
}
|
||
|
|
||
|
// 代码块
|
||
|
const onCodeSubmit = (data) => {
|
||
|
emit('editor-event', {
|
||
|
event: 'code_event',
|
||
|
data,
|
||
|
callBack: () => {}
|
||
|
})
|
||
|
isShowCode.value = false
|
||
|
}
|
||
|
|
||
|
// 投票
|
||
|
const onVoteSubmit = (data) => {
|
||
|
emit('editor-event', {
|
||
|
event: 'vote_event',
|
||
|
data,
|
||
|
callBack: () => {}
|
||
|
})
|
||
|
isShowVote.value = false
|
||
|
}
|
||
|
|
||
|
// 录音
|
||
|
const onRecorderFinish = (data) => {
|
||
|
emit('editor-event', {
|
||
|
event: 'file_event',
|
||
|
data
|
||
|
})
|
||
|
isShowRecorder.value = false
|
||
|
}
|
||
|
</script>
|
||
|
|
||
|
<template>
|
||
|
<section class="el-container editor">
|
||
|
<section class="el-container is-vertical">
|
||
|
<!-- 工具栏区域 -->
|
||
|
<header class="el-header toolbar bdr-t">
|
||
|
<div class="tools">
|
||
|
<!-- 表情选择器弹出框 -->
|
||
|
<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>
|
||
|
|
||
|
<MeEditorEmoticon @on-select="onEmoticonEvent" />
|
||
|
</n-popover>
|
||
|
|
||
|
<!-- 工具栏其他功能按钮 -->
|
||
|
<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>
|
||
|
|
||
|
<!-- 编辑器主体区域 -->
|
||
|
<main class="el-main height100">
|
||
|
<div
|
||
|
ref="editorRef"
|
||
|
class="custom-editor"
|
||
|
contenteditable="true"
|
||
|
:placeholder="placeholder"
|
||
|
@input="handleInput"
|
||
|
@keydown="handleKeydown"
|
||
|
@paste="handlePaste"
|
||
|
@focus="handleFocus"
|
||
|
@blur="handleBlur"
|
||
|
></div>
|
||
|
|
||
|
<!-- @提及列表 -->
|
||
|
<div
|
||
|
v-if="showMention"
|
||
|
class="mention-list"
|
||
|
:style="{ top: mentionPosition.top + 'px', left: mentionPosition.left + 'px' }"
|
||
|
>
|
||
|
<ul>
|
||
|
<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>
|
||
|
|
||
|
<!-- 隐藏的文件上传表单 -->
|
||
|
<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>
|
||
|
.editor {
|
||
|
--tip-bg-color: rgb(241 241 241 / 90%);
|
||
|
height: 100%;
|
||
|
|
||
|
.toolbar {
|
||
|
height: 38px;
|
||
|
display: flex;
|
||
|
|
||
|
.tools {
|
||
|
height: 100%;
|
||
|
flex: auto;
|
||
|
display: 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: #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(.editor-emoji) {
|
||
|
display: inline-block;
|
||
|
width: 24px;
|
||
|
height: 24px;
|
||
|
vertical-align: middle;
|
||
|
margin: 0 2px;
|
||
|
}
|
||
|
|
||
|
.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 {
|
||
|
width: 3px;
|
||
|
height: 3px;
|
||
|
background-color: unset;
|
||
|
}
|
||
|
|
||
|
&::-webkit-scrollbar-thumb {
|
||
|
border-radius: 3px;
|
||
|
background-color: transparent;
|
||
|
}
|
||
|
|
||
|
&:hover {
|
||
|
&::-webkit-scrollbar-thumb {
|
||
|
background-color: var(--im-scrollbar-thumb);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
.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 样式 */
|
||
|
.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;
|
||
|
max-height: 200px;
|
||
|
overflow-y: auto;
|
||
|
|
||
|
ul {
|
||
|
list-style: none;
|
||
|
padding: 0;
|
||
|
margin: 0;
|
||
|
}
|
||
|
|
||
|
li {
|
||
|
padding: 8px 12px;
|
||
|
cursor: pointer;
|
||
|
font-size: 14px;
|
||
|
|
||
|
&:hover,
|
||
|
&.selected {
|
||
|
background-color: #f5f5f5;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/* 引用卡片样式 */
|
||
|
.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;
|
||
|
}
|
||
|
|
||
|
/* 编辑提示样式 */
|
||
|
.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;
|
||
|
}
|
||
|
|
||
|
.edit-tip button {
|
||
|
background: none;
|
||
|
border: none;
|
||
|
cursor: pointer;
|
||
|
color: #d46b08;
|
||
|
margin-left: auto;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/* 暗色模式样式调整 */
|
||
|
html[theme-mode='dark'] {
|
||
|
.editor {
|
||
|
--tip-bg-color: #48484d;
|
||
|
}
|
||
|
}
|
||
|
</style>
|