2025-05-29 08:59:59 +00:00
|
|
|
|
<script setup>
|
2025-05-30 03:59:36 +00:00
|
|
|
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, markRaw, watch } from 'vue'
|
2025-05-29 08:59:59 +00:00
|
|
|
|
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()
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
// 当前会话索引
|
|
|
|
|
const indexName = computed(() => dialogueStore.index_name)
|
|
|
|
|
|
2025-05-29 08:59:59 +00:00
|
|
|
|
// 工具栏配置
|
|
|
|
|
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
|
2025-05-30 03:59:36 +00:00
|
|
|
|
|
|
|
|
|
// 获取编辑器内容,但不包括引用元素的内容
|
|
|
|
|
const editorClone = target.cloneNode(true)
|
|
|
|
|
const quoteElements = editorClone.querySelectorAll('.editor-quote')
|
|
|
|
|
quoteElements.forEach(quote => quote.remove())
|
|
|
|
|
|
|
|
|
|
// 更新编辑器内容(不包含引用)
|
|
|
|
|
editorContent.value = editorClone.textContent || ''
|
2025-05-29 08:59:59 +00:00
|
|
|
|
editorHtml.value = target.innerHTML || ''
|
|
|
|
|
|
|
|
|
|
// 检查@mention
|
|
|
|
|
checkMention(target)
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
// 保存草稿
|
|
|
|
|
saveDraft()
|
|
|
|
|
|
2025-05-29 08:59:59 +00:00
|
|
|
|
// 发送输入事件
|
|
|
|
|
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
|
|
|
|
|
}
|
2025-06-03 06:17:20 +00:00
|
|
|
|
console.log('键盘事件:', event.key, 'Ctrl:', event.ctrlKey, 'Meta:', event.metaKey);
|
2025-05-29 08:59:59 +00:00
|
|
|
|
|
2025-06-03 06:17:20 +00:00
|
|
|
|
// 处理Enter键发送消息(只有在没有按Ctrl/Cmd时才发送)
|
|
|
|
|
if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey) {
|
|
|
|
|
console.log('Enter发送消息');
|
2025-05-29 08:59:59 +00:00
|
|
|
|
event.preventDefault()
|
|
|
|
|
// 确保编辑器内容不为空
|
|
|
|
|
if (editorContent.value.trim() || editorHtml.value.includes('<img') || editorHtml.value.includes('editor-file')) {
|
|
|
|
|
sendMessage()
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-03 06:17:20 +00:00
|
|
|
|
|
|
|
|
|
// Ctrl+Enter换行由WangEditor的onKeyDown处理,这里不需要额外处理
|
2025-05-29 08:59:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 发送消息
|
|
|
|
|
const sendMessage = () => {
|
2025-05-30 03:59:36 +00:00
|
|
|
|
console.log('发送消息');
|
2025-05-29 08:59:59 +00:00
|
|
|
|
// 检查编辑器是否有内容:文本、图片
|
|
|
|
|
if (!editorContent.value.trim() && !editorHtml.value.includes('<img') && !editorHtml.value.includes('editor-file')) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-05-30 03:59:36 +00:00
|
|
|
|
console.log('发送消息1');
|
2025-05-29 08:59:59 +00:00
|
|
|
|
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' :
|
2025-05-30 03:59:36 +00:00
|
|
|
|
messageData.items[0].type === 1 ? 'text_event' :
|
2025-05-29 08:59:59 +00:00
|
|
|
|
messageData.items[0].type + '_event'
|
2025-05-30 03:59:36 +00:00
|
|
|
|
console.log('发送消息2',eventType);
|
2025-05-29 08:59:59 +00:00
|
|
|
|
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({
|
2025-05-30 03:59:36 +00:00
|
|
|
|
type: 1,
|
2025-05-29 08:59:59 +00:00
|
|
|
|
content: textContent.trim()
|
|
|
|
|
})
|
|
|
|
|
textContent = ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items.push({
|
2025-05-30 03:59:36 +00:00
|
|
|
|
type: 3,
|
2025-05-29 08:59:59 +00:00
|
|
|
|
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({
|
2025-05-30 03:59:36 +00:00
|
|
|
|
type: 1,
|
2025-05-29 08:59:59 +00:00
|
|
|
|
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({
|
2025-05-30 03:59:36 +00:00
|
|
|
|
type: 1,
|
2025-05-29 08:59:59 +00:00
|
|
|
|
content: textContent.trim()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2025-05-30 03:59:36 +00:00
|
|
|
|
items: items.length > 0 ? items : [{ type: 1, content: '' }],
|
2025-05-29 08:59:59 +00:00
|
|
|
|
mentionUids,
|
|
|
|
|
quoteId: quoteData.value?.msg_id || 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 清空编辑器
|
|
|
|
|
const clearEditor = () => {
|
|
|
|
|
editorContent.value = ''
|
|
|
|
|
editorHtml.value = ''
|
|
|
|
|
if (editorRef.value) {
|
|
|
|
|
editorRef.value.innerHTML = ''
|
|
|
|
|
}
|
2025-05-30 03:59:36 +00:00
|
|
|
|
|
|
|
|
|
// 清除引用数据
|
2025-05-29 08:59:59 +00:00
|
|
|
|
quoteData.value = null
|
|
|
|
|
hideMentionList()
|
2025-05-30 03:59:36 +00:00
|
|
|
|
|
|
|
|
|
// 清空草稿
|
|
|
|
|
saveDraft()
|
|
|
|
|
|
|
|
|
|
// 触发输入事件
|
|
|
|
|
emit('editor-event', {
|
|
|
|
|
event: 'input_event',
|
|
|
|
|
data: ''
|
|
|
|
|
})
|
2025-05-29 08:59:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 插入图片
|
|
|
|
|
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) => {
|
2025-05-30 03:59:36 +00:00
|
|
|
|
// 保存引用数据,但不保存到草稿中
|
2025-05-29 08:59:59 +00:00
|
|
|
|
quoteData.value = data
|
2025-05-30 03:59:36 +00:00
|
|
|
|
|
|
|
|
|
// 在编辑器中显示引用内容
|
|
|
|
|
const editor = editorRef.value
|
|
|
|
|
if (!editor) return
|
|
|
|
|
|
|
|
|
|
// 先移除已有的引用元素
|
|
|
|
|
const existingQuotes = editor.querySelectorAll('.editor-quote')
|
|
|
|
|
existingQuotes.forEach(quote => quote.remove())
|
|
|
|
|
|
|
|
|
|
// 保存当前光标位置
|
|
|
|
|
const selection = window.getSelection()
|
|
|
|
|
const savedRange = selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null
|
|
|
|
|
const hasContent = editor.textContent.trim().length > 0
|
|
|
|
|
|
|
|
|
|
// 创建引用元素
|
|
|
|
|
const quoteElement = document.createElement('div')
|
|
|
|
|
quoteElement.className = 'editor-quote'
|
|
|
|
|
quoteElement.contentEditable = 'false' // 设置为不可编辑
|
|
|
|
|
|
|
|
|
|
// 添加引用内容和关闭按钮
|
|
|
|
|
quoteElement.innerHTML = `
|
|
|
|
|
<div class="quote-content-wrapper">
|
|
|
|
|
<div class="quote-title">${data.title}</div>
|
|
|
|
|
${data.image ? `<div class="quote-image"><img src="${data.image}" alt="引用图片" /></div>` : ''}
|
|
|
|
|
${data.describe ? `<div class="quote-content">${data.describe}</div>` : ''}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="quote-close">×</div>
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
// 将引用元素插入到编辑器开头
|
|
|
|
|
if (editor.firstChild) {
|
|
|
|
|
editor.insertBefore(quoteElement, editor.firstChild)
|
|
|
|
|
} else {
|
|
|
|
|
editor.appendChild(quoteElement)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 添加关闭按钮点击事件
|
|
|
|
|
const closeBtn = quoteElement.querySelector('.quote-close')
|
|
|
|
|
if (closeBtn) {
|
|
|
|
|
closeBtn.addEventListener('click', () => {
|
|
|
|
|
// 移除引用元素
|
|
|
|
|
quoteElement.remove()
|
|
|
|
|
|
|
|
|
|
// 清除引用数据
|
|
|
|
|
quoteData.value = null
|
|
|
|
|
|
|
|
|
|
// 不触发handleInput,避免保存草稿
|
|
|
|
|
// 只更新编辑器内容变量
|
|
|
|
|
editorContent.value = editor.textContent || ''
|
|
|
|
|
editorHtml.value = editor.innerHTML || ''
|
|
|
|
|
|
|
|
|
|
// 确保编辑器获得焦点
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
editor.focus()
|
|
|
|
|
}, 0)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 注意:不调用saveDraft(),确保引用内容不会被保存到草稿中
|
|
|
|
|
|
|
|
|
|
// 添加点击整个引用卡片的事件
|
|
|
|
|
quoteElement.addEventListener('click', (e) => {
|
|
|
|
|
// 如果不是点击关闭按钮,则设置光标到引用卡片后面
|
|
|
|
|
if (!e.target.classList.contains('quote-close')) {
|
|
|
|
|
const selection = window.getSelection()
|
|
|
|
|
const range = document.createRange()
|
|
|
|
|
range.setStartAfter(quoteElement)
|
|
|
|
|
range.collapse(true)
|
|
|
|
|
selection.removeAllRanges()
|
|
|
|
|
selection.addRange(range)
|
|
|
|
|
|
|
|
|
|
// 确保编辑器获得焦点
|
|
|
|
|
editor.focus()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 监听键盘事件,处理删除操作
|
|
|
|
|
// 监听键盘事件,处理删除操作
|
|
|
|
|
const handleDeleteQuote = function(e) {
|
|
|
|
|
// 检查是否是删除键(Backspace 或 Delete)
|
|
|
|
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
if (selection.rangeCount === 0) return;
|
|
|
|
|
|
|
|
|
|
const range = selection.getRangeAt(0);
|
|
|
|
|
const quoteElement = editor.querySelector('.editor-quote');
|
|
|
|
|
|
|
|
|
|
if (!quoteElement) {
|
|
|
|
|
// 如果引用元素已经被删除,移除事件监听器
|
|
|
|
|
editor.removeEventListener('keydown', handleDeleteQuote);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 检查光标是否在引用卡片前面(Backspace)或后面(Delete)
|
|
|
|
|
const isBeforeQuote = e.key === 'Backspace' &&
|
|
|
|
|
range.collapsed &&
|
|
|
|
|
range.startContainer === editor &&
|
|
|
|
|
Array.from(editor.childNodes).indexOf(quoteElement) === range.startOffset;
|
|
|
|
|
|
|
|
|
|
const isAfterQuote = e.key === 'Delete' &&
|
|
|
|
|
range.collapsed &&
|
|
|
|
|
range.startContainer === editor &&
|
|
|
|
|
Array.from(editor.childNodes).indexOf(quoteElement) === range.startOffset - 1;
|
|
|
|
|
|
|
|
|
|
if (isBeforeQuote || isAfterQuote) {
|
|
|
|
|
quoteElement.remove();
|
|
|
|
|
quoteData.value = null;
|
|
|
|
|
handleInput({ target: editor });
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
editor.addEventListener('keydown', handleDeleteQuote);
|
|
|
|
|
|
|
|
|
|
// 设置光标位置
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
// 确保编辑器中有内容可以放置光标
|
|
|
|
|
if (!editor.childNodes.length || (editor.childNodes.length === 1 && editor.childNodes[0] === quoteElement)) {
|
|
|
|
|
// 如果编辑器中只有引用元素,添加一个空的文本节点
|
|
|
|
|
const textNode = document.createTextNode('\u200B'); // 使用零宽空格
|
|
|
|
|
editor.appendChild(textNode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 强制刷新编辑器内容,确保DOM已更新
|
|
|
|
|
const currentHtml = editor.innerHTML;
|
|
|
|
|
editor.innerHTML = currentHtml;
|
|
|
|
|
|
|
|
|
|
// 重新获取引用元素(因为innerHTML操作会导致之前的引用丢失)
|
|
|
|
|
const newQuoteElement = editor.querySelector('.editor-quote');
|
|
|
|
|
|
|
|
|
|
editor.focus();
|
|
|
|
|
const newSelection = window.getSelection();
|
|
|
|
|
const newRange = document.createRange();
|
|
|
|
|
|
|
|
|
|
// 始终将光标设置在引用元素后面
|
|
|
|
|
if (newQuoteElement) {
|
|
|
|
|
// 找到引用元素后的第一个节点
|
|
|
|
|
let nextNode = newQuoteElement.nextSibling;
|
|
|
|
|
if (nextNode && nextNode.nodeType === 3) { // 文本节点
|
|
|
|
|
newRange.setStart(nextNode, 0);
|
|
|
|
|
} else if (nextNode) { // 其他元素节点
|
|
|
|
|
newRange.setStartBefore(nextNode);
|
|
|
|
|
} else { // 没有下一个节点
|
|
|
|
|
newRange.setStartAfter(newQuoteElement);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// 如果找不到引用元素,则设置到编辑器开头
|
|
|
|
|
if (editor.firstChild) {
|
|
|
|
|
newRange.setStartBefore(editor.firstChild);
|
|
|
|
|
} else {
|
|
|
|
|
newRange.setStart(editor, 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newRange.collapse(true);
|
|
|
|
|
newSelection.removeAllRanges();
|
|
|
|
|
newSelection.addRange(newRange);
|
|
|
|
|
|
|
|
|
|
// 确保光标可见
|
|
|
|
|
editor.scrollTop = editor.scrollHeight;
|
|
|
|
|
|
|
|
|
|
// 更新编辑器内容
|
|
|
|
|
handleInput({ target: editor });
|
|
|
|
|
|
|
|
|
|
// 再次确保编辑器获得焦点
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
editor.focus();
|
|
|
|
|
// 再次尝试设置光标位置
|
|
|
|
|
if (newSelection.rangeCount === 0) {
|
|
|
|
|
const finalRange = document.createRange();
|
|
|
|
|
if (newQuoteElement) {
|
|
|
|
|
finalRange.setStartAfter(newQuoteElement);
|
|
|
|
|
} else {
|
|
|
|
|
finalRange.setStart(editor, 0);
|
|
|
|
|
}
|
|
|
|
|
finalRange.collapse(true);
|
|
|
|
|
newSelection.removeAllRanges();
|
|
|
|
|
newSelection.addRange(finalRange);
|
|
|
|
|
}
|
|
|
|
|
}, 50);
|
|
|
|
|
}, 200); // 增加延时确保DOM已完全更新
|
2025-05-29 08:59:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onSubscribeEdit = (data) => {
|
|
|
|
|
editingMessage.value = data
|
|
|
|
|
clearEditor()
|
|
|
|
|
|
|
|
|
|
// 插入编辑内容
|
|
|
|
|
if (data.content) {
|
|
|
|
|
editorRef.value.innerHTML = data.content
|
|
|
|
|
editorContent.value = data.content
|
|
|
|
|
editorHtml.value = data.content
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
// 清除编辑器内容的事件处理函数
|
|
|
|
|
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 })
|
|
|
|
|
|
2025-05-29 08:59:59 +00:00
|
|
|
|
// 组件挂载
|
|
|
|
|
onMounted(() => {
|
2025-05-30 03:59:36 +00:00
|
|
|
|
bus.subscribe(EditorConst.Mention, onSubscribeMention)
|
|
|
|
|
bus.subscribe(EditorConst.Quote, onSubscribeQuote)
|
|
|
|
|
bus.subscribe(EditorConst.Edit, onSubscribeEdit)
|
|
|
|
|
bus.subscribe(EditorConst.Clear, onSubscribeClear)
|
2025-05-29 08:59:59 +00:00
|
|
|
|
|
|
|
|
|
// 点击外部隐藏mention
|
|
|
|
|
document.addEventListener('click', (event) => {
|
|
|
|
|
if (!editorRef.value?.contains(event.target)) {
|
|
|
|
|
hideMentionList()
|
|
|
|
|
}
|
|
|
|
|
})
|
2025-05-30 03:59:36 +00:00
|
|
|
|
|
|
|
|
|
// 初始加载草稿
|
|
|
|
|
loadDraft()
|
2025-05-29 08:59:59 +00:00
|
|
|
|
})
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 组件生命周期钩子 - 组件卸载前
|
|
|
|
|
*
|
|
|
|
|
* onBeforeUnmount是Vue 3的生命周期钩子,在组件卸载前执行
|
|
|
|
|
* 在这里用于清理事件订阅,防止内存泄漏
|
|
|
|
|
* 使用bus.unsubscribe取消订阅之前通过bus.subscribe注册的事件处理函数
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
onBeforeUnmount(() => {
|
2025-05-30 03:59:36 +00:00
|
|
|
|
// 取消订阅所有编辑器相关事件,防止内存泄漏和事件监听器残留
|
|
|
|
|
bus.unsubscribe(EditorConst.Mention, onSubscribeMention)
|
|
|
|
|
bus.unsubscribe(EditorConst.Quote, onSubscribeQuote)
|
|
|
|
|
bus.unsubscribe(EditorConst.Edit, onSubscribeEdit)
|
|
|
|
|
bus.unsubscribe(EditorConst.Clear, onSubscribeClear)
|
2025-06-03 06:17:20 +00:00
|
|
|
|
|
|
|
|
|
// 清理DOM事件监听器
|
|
|
|
|
const editor = editorRef.value
|
|
|
|
|
if (editor && handleDeleteQuote) {
|
|
|
|
|
editor.removeEventListener('keydown', handleDeleteQuote)
|
|
|
|
|
}
|
2025-05-29 08:59:59 +00:00
|
|
|
|
})
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 表情选择事件处理函数
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} emoji - 选中的表情对象
|
|
|
|
|
*
|
|
|
|
|
* 当用户从表情选择器中选择表情时触发
|
|
|
|
|
* 调用onEmoticonEvent插入表情到编辑器
|
|
|
|
|
* 然后关闭表情选择器面板
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
const onEmoticonSelect = (emoji) => {
|
|
|
|
|
// 直接调用onEmoticonEvent处理表情
|
|
|
|
|
onEmoticonEvent(emoji)
|
|
|
|
|
isShowEmoticon.value = false
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 代码块提交事件处理函数
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} data - 代码块数据
|
|
|
|
|
*
|
|
|
|
|
* 当用户提交代码块时触发
|
|
|
|
|
* 通过emit向父组件发送code_event事件
|
|
|
|
|
* 传递代码块数据并关闭代码块面板
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
const onCodeSubmit = (data) => {
|
|
|
|
|
emit('editor-event', {
|
|
|
|
|
event: 'code_event',
|
|
|
|
|
data,
|
|
|
|
|
callBack: () => {}
|
|
|
|
|
})
|
|
|
|
|
isShowCode.value = false
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 投票提交事件处理函数
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} data - 投票数据
|
|
|
|
|
*
|
|
|
|
|
* 当用户提交投票时触发
|
|
|
|
|
* 通过emit向父组件发送vote_event事件
|
|
|
|
|
* 传递投票数据并关闭投票面板
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
const onVoteSubmit = (data) => {
|
|
|
|
|
emit('editor-event', {
|
|
|
|
|
event: 'vote_event',
|
|
|
|
|
data,
|
|
|
|
|
callBack: () => {}
|
|
|
|
|
})
|
|
|
|
|
isShowVote.value = false
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 录音完成事件处理函数
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} data - 录音数据
|
|
|
|
|
*
|
|
|
|
|
* 当用户完成录音时触发
|
|
|
|
|
* 通过emit向父组件发送file_event事件
|
|
|
|
|
* 传递录音文件数据并关闭录音面板
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
const onRecorderFinish = (data) => {
|
|
|
|
|
emit('editor-event', {
|
|
|
|
|
event: 'file_event',
|
|
|
|
|
data
|
|
|
|
|
})
|
|
|
|
|
isShowRecorder.value = false
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
编辑器容器组件
|
|
|
|
|
使用el-container布局组件创建垂直布局的编辑器
|
|
|
|
|
包含工具栏和编辑区域两个主要部分
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<section class="el-container editor">
|
|
|
|
|
<section class="el-container is-vertical">
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
工具栏区域
|
|
|
|
|
使用el-header组件作为工具栏容器
|
|
|
|
|
包含各种编辑工具按钮
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<header class="el-header toolbar bdr-t">
|
|
|
|
|
<div class="tools">
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
表情选择器弹出框
|
|
|
|
|
使用n-popover组件创建悬浮的表情选择面板
|
|
|
|
|
通过trigger="click"设置点击触发
|
|
|
|
|
raw属性表示内容不会被包装在额外的div中
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<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"
|
|
|
|
|
>
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
触发器模板
|
|
|
|
|
点击此区域会显示表情选择器
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<template #trigger>
|
|
|
|
|
<div class="item pointer">
|
|
|
|
|
<n-icon size="18" class="icon" :component="SmilingFace" />
|
|
|
|
|
<p class="tip-title">表情符号</p>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
表情选择器组件
|
|
|
|
|
通过on-select事件将选中的表情传递给onEmoticonEvent处理函数
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<MeEditorEmoticon @on-select="onEmoticonEvent" />
|
|
|
|
|
</n-popover>
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
工具栏其他功能按钮
|
|
|
|
|
通过v-for循环渲染navs数组中定义的工具按钮
|
|
|
|
|
每个按钮包含图标和提示文本
|
|
|
|
|
通过v-show="nav.show"控制按钮的显示/隐藏
|
|
|
|
|
点击时执行nav.click定义的函数
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
编辑器主体区域
|
|
|
|
|
使用el-main组件作为主要内容区域
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<main class="el-main height100">
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
可编辑区域
|
|
|
|
|
使用contenteditable="true"使div可编辑,这是HTML5的属性
|
|
|
|
|
ref="editorRef"用于获取DOM引用
|
|
|
|
|
placeholder属性用于显示占位文本
|
|
|
|
|
绑定多个事件处理函数处理用户交互
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<div
|
|
|
|
|
ref="editorRef"
|
|
|
|
|
class="custom-editor"
|
|
|
|
|
contenteditable="true"
|
|
|
|
|
:placeholder="placeholder"
|
|
|
|
|
@input="handleInput"
|
|
|
|
|
@keydown="handleKeydown"
|
|
|
|
|
@paste="handlePaste"
|
|
|
|
|
@focus="handleFocus"
|
|
|
|
|
@blur="handleBlur"
|
|
|
|
|
></div>
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
@提及列表
|
|
|
|
|
当用户输入@时显示的用户列表
|
|
|
|
|
通过v-if="showMention"控制显示/隐藏
|
|
|
|
|
使用动态样式定位列表位置
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<div
|
|
|
|
|
v-if="showMention"
|
|
|
|
|
class="mention-list"
|
|
|
|
|
:style="{ top: mentionPosition.top + 'px', left: mentionPosition.left + 'px' }"
|
|
|
|
|
>
|
|
|
|
|
<ul>
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
提及列表项
|
|
|
|
|
循环渲染mentionList数组中的用户
|
|
|
|
|
通过:class="{ selected: index === selectedMentionIndex }"高亮当前选中项
|
|
|
|
|
@mousedown.prevent防止失去焦点
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
<!--
|
|
|
|
|
隐藏的文件上传表单
|
|
|
|
|
使用display: none隐藏表单
|
|
|
|
|
包含两个文件输入框,分别用于上传图片和其他文件
|
|
|
|
|
通过ref获取DOM引用
|
|
|
|
|
-->
|
2025-05-29 08:59:59 +00:00
|
|
|
|
<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>
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 编辑器组件样式
|
|
|
|
|
* 使用CSS变量定义可复用的颜色值
|
|
|
|
|
* --tip-bg-color: 提示背景色
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.editor {
|
|
|
|
|
--tip-bg-color: rgb(241 241 241 / 90%);
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 工具栏样式
|
|
|
|
|
* 固定高度的顶部工具栏
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.toolbar {
|
|
|
|
|
height: 38px;
|
|
|
|
|
display: flex;
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 工具按钮容器
|
|
|
|
|
* 使用flex布局排列工具按钮
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.tools {
|
|
|
|
|
height: 100%;
|
|
|
|
|
flex: auto;
|
|
|
|
|
display: flex;
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 单个工具按钮样式
|
|
|
|
|
* 使用flex布局居中对齐内容
|
|
|
|
|
* 相对定位用于放置提示文本
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.item {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
width: 35px;
|
|
|
|
|
margin: 0 2px;
|
|
|
|
|
position: relative;
|
2025-05-30 03:59:36 +00:00
|
|
|
|
user-select: none; /* 防止文本被选中 */
|
2025-05-29 08:59:59 +00:00
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 提示文本样式
|
|
|
|
|
* 默认隐藏,悬停时显示
|
|
|
|
|
* 使用绝对定位在按钮下方显示
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.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;
|
2025-05-30 03:59:36 +00:00
|
|
|
|
white-space: pre; /* 保留空白符 */
|
2025-05-29 08:59:59 +00:00
|
|
|
|
user-select: none;
|
2025-05-30 03:59:36 +00:00
|
|
|
|
z-index: 999999999999; /* 确保提示显示在最上层 */
|
2025-05-29 08:59:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 悬停效果
|
|
|
|
|
* 当鼠标悬停在按钮上时显示提示文本
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
&:hover {
|
|
|
|
|
.tip-title {
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 文件链接样式
|
|
|
|
|
* 使用:deep()选择器影响组件内部元素
|
|
|
|
|
* 显示文件链接的卡片式样式
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
: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;
|
2025-05-30 03:59:36 +00:00
|
|
|
|
padding-right: 60px; /* 为文件大小信息留出空间 */
|
2025-05-29 08:59:59 +00:00
|
|
|
|
max-width: 100%;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
overflow: hidden;
|
2025-05-30 03:59:36 +00:00
|
|
|
|
text-overflow: ellipsis; /* 文本溢出时显示省略号 */
|
|
|
|
|
white-space: nowrap; /* 防止文本换行 */
|
2025-05-29 08:59:59 +00:00
|
|
|
|
|
|
|
|
|
&::after {
|
|
|
|
|
content: attr(data-size);
|
|
|
|
|
position: absolute;
|
|
|
|
|
right: 10px;
|
|
|
|
|
color: #757575;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background-color: #e3f2fd;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 表情符号样式
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 表情符号样式
|
|
|
|
|
* 使用:deep()选择器影响组件内部元素
|
|
|
|
|
* 设置表情符号的大小和对齐方式
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
:deep(.editor-emoji) {
|
|
|
|
|
display: inline-block;
|
|
|
|
|
width: 24px;
|
|
|
|
|
height: 24px;
|
2025-05-30 03:59:36 +00:00
|
|
|
|
vertical-align: middle; /* 垂直居中对齐 */
|
2025-05-29 08:59:59 +00:00
|
|
|
|
margin: 0 2px;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 引用消息样式
|
|
|
|
|
* 使用:deep()选择器影响组件内部元素
|
|
|
|
|
* 创建带有左侧边框的引用卡片
|
|
|
|
|
* 使用CSS变量适配不同主题
|
|
|
|
|
*/
|
|
|
|
|
:deep(.editor-quote) {
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
background-color: var(--im-message-left-bg-color, #f5f5f5);
|
|
|
|
|
border-left: 3px solid var(--im-primary-color, #409eff);
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
position: relative;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* 轻微阴影效果 */
|
|
|
|
|
cursor: pointer; /* 添加指针样式 */
|
|
|
|
|
user-select: none; /* 防止文本选择 */
|
|
|
|
|
transition: background-color 0.2s ease; /* 平滑过渡效果 */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 引用卡片悬停效果
|
|
|
|
|
* 改变背景色提供视觉反馈
|
|
|
|
|
*/
|
|
|
|
|
&:hover {
|
|
|
|
|
background-color: var(--im-message-left-bg-hover-color, #eaeaea);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 引用内容容器
|
|
|
|
|
* 使用flex: 1占据剩余空间
|
|
|
|
|
* 处理内容溢出
|
|
|
|
|
*/
|
|
|
|
|
.quote-content-wrapper {
|
|
|
|
|
flex: 1;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 引用标题样式
|
|
|
|
|
* 使用主题色显示引用来源
|
|
|
|
|
*/
|
|
|
|
|
.quote-title {
|
|
|
|
|
color: var(--im-primary-color, #409eff);
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 引用内容样式
|
|
|
|
|
* 限制显示行数,超出部分显示省略号
|
|
|
|
|
* 使用-webkit-line-clamp实现多行文本截断
|
|
|
|
|
*/
|
|
|
|
|
.quote-content {
|
|
|
|
|
color: var(--im-text-color, #333);
|
|
|
|
|
word-break: break-all; /* 在任意字符间断行 */
|
|
|
|
|
white-space: normal; /* 允许正常换行 */
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis; /* 文本溢出显示省略号 */
|
|
|
|
|
display: -webkit-box;
|
|
|
|
|
-webkit-line-clamp: 2; /* 限制显示2行 */
|
|
|
|
|
-webkit-box-orient: vertical;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 引用图片样式
|
|
|
|
|
* 限制图片大小,添加圆角
|
|
|
|
|
*/
|
|
|
|
|
.quote-image img {
|
|
|
|
|
max-width: 100px;
|
|
|
|
|
max-height: 60px;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
pointer-events: none; /* 防止图片被拖拽 */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 引用关闭按钮样式
|
|
|
|
|
* 圆形按钮,悬停时改变背景色和文字颜色
|
|
|
|
|
*/
|
|
|
|
|
.quote-close {
|
|
|
|
|
width: 18px;
|
|
|
|
|
height: 18px;
|
|
|
|
|
line-height: 16px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background-color: rgba(0, 0, 0, 0.1);
|
|
|
|
|
color: #666;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
margin-left: 8px;
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background-color: rgba(0, 0, 0, 0.2);
|
|
|
|
|
color: #333;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.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;
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 自定义滚动条样式 - 轨道
|
|
|
|
|
* 使用::-webkit-scrollbar伪元素自定义滚动条轨道
|
|
|
|
|
* 设置宽度和高度为3px,背景色为透明
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
&::-webkit-scrollbar {
|
|
|
|
|
width: 3px;
|
|
|
|
|
height: 3px;
|
|
|
|
|
background-color: unset;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 自定义滚动条样式 - 滑块
|
|
|
|
|
* 使用::-webkit-scrollbar-thumb伪元素自定义滚动条滑块
|
|
|
|
|
* 设置圆角和透明背景色
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
&::-webkit-scrollbar-thumb {
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 滚动条悬停效果
|
|
|
|
|
* 当鼠标悬停在编辑器上时,显示滚动条滑块
|
|
|
|
|
* 使用CSS变量适配不同主题的滚动条颜色
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
&:hover {
|
|
|
|
|
&::-webkit-scrollbar-thumb {
|
|
|
|
|
background-color: var(--im-scrollbar-thumb);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 编辑器占位符样式
|
|
|
|
|
* 使用:empty::before伪元素在编辑器为空时显示占位文本
|
|
|
|
|
* content: attr(placeholder)获取placeholder属性的值作为显示内容
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.custom-editor:empty::before {
|
|
|
|
|
content: attr(placeholder);
|
|
|
|
|
color: #999;
|
2025-05-30 03:59:36 +00:00
|
|
|
|
pointer-events: none; /* 防止占位符文本接收鼠标事件 */
|
2025-05-29 08:59:59 +00:00
|
|
|
|
font-family: PingFang SC, Microsoft YaHei, 'Alibaba PuHuiTi 2.0 45' !important;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 编辑器聚焦样式
|
|
|
|
|
* 移除默认的聚焦轮廓
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.custom-editor:focus {
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* @提及样式
|
|
|
|
|
* 为@提及的用户名添加特殊样式
|
|
|
|
|
* 使用蓝色背景和文字颜色突出显示
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.mention {
|
|
|
|
|
color: #1890ff;
|
|
|
|
|
background-color: #e6f7ff;
|
|
|
|
|
padding: 2px 4px;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* @提及悬停效果
|
|
|
|
|
* 当鼠标悬停在@提及上时改变背景色
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.mention:hover {
|
|
|
|
|
background-color: #bae7ff;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 图片样式
|
|
|
|
|
* 限制编辑器中插入的图片大小
|
|
|
|
|
* 添加圆角和鼠标指针样式
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.editor-image {
|
|
|
|
|
max-width: 100px;
|
|
|
|
|
max-height: 100px;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
background-color: #48484d;
|
|
|
|
|
margin: 0px 2px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 表情样式
|
|
|
|
|
* 设置表情图片的大小和对齐方式
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.editor-emoji {
|
|
|
|
|
width: 20px;
|
|
|
|
|
height: 20px;
|
|
|
|
|
vertical-align: middle;
|
|
|
|
|
margin: 0 2px;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* @提及列表样式
|
|
|
|
|
* 定位和样式化@提及的下拉列表
|
|
|
|
|
* 使用绝对定位、白色背景和阴影效果
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.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;
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 列表容器样式
|
|
|
|
|
* 移除默认的列表样式
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
ul {
|
|
|
|
|
list-style: none;
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 列表项样式
|
|
|
|
|
* 设置内边距、鼠标指针和字体大小
|
|
|
|
|
* 添加悬停和选中状态的背景色变化
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 引用内容样式
|
|
|
|
|
* 设置较小的字体大小
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.quote-content {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 引用标题样式
|
|
|
|
|
* 使用粗体和主题蓝色
|
|
|
|
|
* 添加底部间距
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.quote-title {
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
color: #1890ff;
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 引用文本样式
|
|
|
|
|
* 使用灰色文本和适当的行高
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.quote-text {
|
|
|
|
|
color: #666;
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 引用关闭按钮样式
|
|
|
|
|
* 绝对定位在右上角
|
|
|
|
|
* 移除默认按钮样式,添加鼠标指针
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.quote-close {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 4px;
|
|
|
|
|
right: 4px;
|
|
|
|
|
background: none;
|
|
|
|
|
border: none;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
color: #999;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 编辑提示样式
|
|
|
|
|
* 使用橙色背景和边框创建警示效果
|
|
|
|
|
* 添加内边距、圆角和适当的字体大小
|
|
|
|
|
* 使用flex布局对齐内容
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 编辑提示按钮样式
|
|
|
|
|
* 移除默认按钮样式,添加鼠标指针
|
|
|
|
|
* 使用margin-left: auto将按钮推到右侧
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
.edit-tip button {
|
|
|
|
|
background: none;
|
|
|
|
|
border: none;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
color: #d46b08;
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 03:59:36 +00:00
|
|
|
|
/**
|
|
|
|
|
* 暗色模式样式调整
|
|
|
|
|
* 使用HTML属性选择器检测暗色主题
|
|
|
|
|
* 重新定义暗色模式下的CSS变量
|
|
|
|
|
*/
|
2025-05-29 08:59:59 +00:00
|
|
|
|
html[theme-mode='dark'] {
|
|
|
|
|
.editor {
|
|
|
|
|
--tip-bg-color: #48484d;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|