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

2811 lines
86 KiB
Vue
Raw Normal View History

2025-06-05 08:21:39 +00:00
<script setup>
/**
* CustomEditor 组件
*
* 这是一个自定义富文本编辑器组件支持以下功能
* - 文本编辑和格式化
* - 表情符号插入
* - 图片上传和插入
* - 文件上传
* - @提及功能
* - 引用回复
* - 消息编辑
* - 草稿保存和恢复
*/
// Vue 核心功能导入
import { ref, computed, onMounted, onBeforeUnmount, nextTick, markRaw, watch } from 'vue'
// Naive UI 组件导入
2025-06-05 08:21:39 +00:00
import { NPopover, NIcon } from 'naive-ui'
// 图标导入
2025-06-05 08:21:39 +00:00
import {
SmilingFace, // 表情图标
Pic, // 图片图标
FolderUpload // 文件上传图标
2025-06-05 08:21:39 +00:00
} from '@icon-park/vue-next'
import {IosSend} from '@vicons/ionicons4' // 发送图标
// 工具和API导入
import { bus } from '@/utils/event-bus' // 事件总线工具,用于组件间通信
import { EditorConst } from '@/constant/event-bus' // 编辑器相关的事件常量定义
import { emitCall } from '@/utils/common' // 通用工具函数,用于创建事件数据
import { deltaToMessage, deltaToString, isEmptyDelta } from './util' // 编辑器内容处理工具函数
import { uploadImg } from '@/api/upload' // 图片上传API
import { defAvatar } from '@/constant/default' // 默认头像常量
import { getImageInfo } from '@/utils/functions' // 获取图片信息函数
// Store导入
import { useDialogueStore, useEditorDraftStore, useUserStore } from '@/store' // Pinia Store
// 子组件导入
import MeEditorEmoticon from './MeEditorEmoticon.vue' // 表情选择器组件
/**
* 组件属性定义
*/
2025-06-05 08:21:39 +00:00
const props = defineProps({
vote: {
type: Boolean,
default: false,
// 是否启用投票功能
2025-06-05 08:21:39 +00:00
},
members: {
type: Array,
default: () => [],
// 可@提及的成员列表
},
placeholder: {
type: String,
default: 'Enter-发送消息 [Ctrl+Enter/Shift+Enter]-换行',
// 编辑器占位文本
2025-06-05 08:21:39 +00:00
}
})
/**
* 组件事件定义
* editor-event: 编辑器各种事件的统一出口通过event字段区分不同事件类型
*/
2025-06-05 08:21:39 +00:00
const emit = defineEmits(['editor-event'])
/**
* Store实例
*/
const userStore = useUserStore() // 用户信息Store
const dialogueStore = useDialogueStore() // 对话信息Store
console.log('dialogueStore',dialogueStore.talk.talk_type)
const editorDraftStore = useEditorDraftStore() // 编辑器草稿Store
/**
* 编辑器核心状态
*/
const editorRef = ref(null) // 编辑器DOM引用
const content = ref('') // 编辑器内容
const editorContent = ref('') // 编辑器纯文本内容
const editorHtml = ref('') // 编辑器HTML内容
const isFocused = ref(false) // 编辑器是否聚焦
2025-06-05 08:21:39 +00:00
/**
* @提及相关状态
*/
const showMention = ref(false) // 是否显示@提及列表
const mentionQuery = ref('') // @提及搜索查询
const mentionPosition = ref({ top: 0, left: 0 }) // @提及列表位置
const selectedMentionIndex = ref(0) // 当前选中的@提及项索引
const mentionList = ref([]) // 过滤后的@提及成员列表
const currentMentionQuery = ref('') // 当前@提及查询文本
/**
* 其他功能状态
*/
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 indexName = computed(() => dialogueStore.index_name)
/**
* 工具栏按钮配置
* 定义工具栏上的功能按钮包括图标标题和点击事件
*/
const navs = ref([
2025-06-05 08:21:39 +00:00
{
title: '图片', // 按钮标题
icon: markRaw(Pic), // 按钮图标使用markRaw优化性能
show: true, // 是否显示
2025-06-05 08:21:39 +00:00
click: () => {
// 点击触发隐藏的文件输入框,打开图片选择对话框
2025-06-05 08:21:39 +00:00
fileImageRef.value.click()
}
},
{
title: '文件', // 按钮标题
icon: markRaw(FolderUpload), // 按钮图标
show: true, // 是否显示
2025-06-05 08:21:39 +00:00
click: () => {
// 点击触发隐藏的文件输入框,打开文件选择对话框
2025-06-05 08:21:39 +00:00
uploadFileRef.value.click()
}
}
])
/**
* 调试代码 - 延迟打印成员列表
* 用于开发调试查看传入的成员列表
*/
setTimeout(() => {
console.log('props.members', props.members)
}, 1000)
/**
* 计算属性 - 工具栏配置
* 定义编辑器工具栏的配置项包括类型图标和标题
* 返回一个配置数组用于渲染工具栏按钮
*/
2025-06-05 08:21:39 +00:00
const toolbarConfig = computed(() => {
const config = [
{ type: 'emoticon', icon: 'icon-biaoqing', title: '表情' }, // 表情按钮
{ type: 'image', icon: 'icon-image', title: '图片' }, // 图片按钮
{ type: 'file', icon: 'icon-folder-plus', title: '文件' } // 文件按钮
2025-06-05 08:21:39 +00:00
]
return config
})
/**
* 处理编辑器输入事件
*
* @param {Event} event - 输入事件对象
*
* 该函数负责处理编辑器内容变化时的逻辑
* 1. 获取编辑器DOM节点
* 2. 克隆编辑器内容并移除引用元素
* 3. 提取纯文本内容和表情图片
* 4. 检查@提及功能
* 5. 保存草稿
* 6. 触发input_event事件
*/
2025-06-05 08:21:39 +00:00
const handleInput = (event) => {
const editorNode = (event && event.target) ? event.target : editorRef.value; // 获取编辑器DOM节点
2025-06-11 08:54:54 +00:00
if (!editorNode) {
// 如果找不到编辑器节点,直接返回
2025-06-11 08:54:54 +00:00
// console.warn('handleInput called without a valid editor node.');
return;
}
const target = editorNode;
2025-06-11 08:54:54 +00:00
const editorClone = editorNode.cloneNode(true);
editorClone.querySelectorAll('.editor-quote').forEach(quote => quote.remove());
2025-06-11 08:54:54 +00:00
let rawTextContent = editorClone.textContent || '';
2025-06-11 08:54:54 +00:00
const emojiImages = editorClone.querySelectorAll('img.editor-emoji');
if (emojiImages.length > 0) {
emojiImages.forEach(emoji => {
2025-06-11 08:54:54 +00:00
const altText = emoji.getAttribute('alt');
if (altText) {
rawTextContent += altText;
}
2025-06-11 08:54:54 +00:00
});
}
2025-06-11 08:54:54 +00:00
editorContent.value = rawTextContent;
const currentText = editorNode.textContent.trim();
const hasSpecialElements = editorNode.querySelector('img, .editor-file, .mention');
if (currentText === '' && !hasSpecialElements) {
if (editorNode.innerHTML !== '') {
2025-06-11 08:54:54 +00:00
editorNode.innerHTML = '';
}
}
2025-06-11 08:54:54 +00:00
editorHtml.value = editorNode.innerHTML || '';
const currentEditorItems = parseEditorContent().items;
2025-06-11 08:54:54 +00:00
checkMention(target);
saveDraft();
2025-06-05 08:21:39 +00:00
emit('editor-event', {
event: 'input_event',
2025-06-11 08:54:54 +00:00
data: currentEditorItems.reduce((result, item) => {
if (item.type === 1) return result + item.content;
if (item.type === 3) return result + '[图片]';
return result;
2025-06-11 08:54:54 +00:00
}, '')
});
};
2025-06-05 08:21:39 +00:00
/**
* 检查@提及功能
*
* 该函数检查编辑器中是否有@提及操作
* 1. 获取当前选择范围
* 2. 提取光标前的文本
* 3. 使用正则表达式匹配@符号后的内容
* 4. 如果匹配成功显示@提及列表并更新位置
* 5. 如果没有匹配隐藏@提及列表
*
* @param {HTMLElement} target - 编辑器DOM元素
*/
2025-06-05 08:21:39 +00:00
const checkMention = (target) => {
// 只有群聊才启用@功能
if (dialogueStore.talk.talk_type !== 2) {
hideMentionList()
return
}
// 获取当前选择范围
2025-06-05 08:21:39 +00:00
const selection = window.getSelection()
if (!selection.rangeCount) return
// 获取当前光标位置的范围
2025-06-05 08:21:39 +00:00
const range = selection.getRangeAt(0)
// 提取光标前的文本内容
2025-06-05 08:21:39 +00:00
const textBeforeCursor = range.startContainer.textContent?.substring(0, range.startOffset) || ''
// 使用正则表达式匹配@符号后的内容
2025-06-05 08:21:39 +00:00
const mentionMatch = textBeforeCursor.match(/@([^@\s]*)$/)
if (mentionMatch) {
// 如果匹配成功,设置当前@提及查询文本
2025-06-05 08:21:39 +00:00
currentMentionQuery.value = mentionMatch[1]
// 显示@提及列表
2025-06-05 08:21:39 +00:00
showMentionList()
// 更新@提及列表位置
2025-06-05 08:21:39 +00:00
updateMentionPosition(range)
} else {
// 如果没有匹配,隐藏@提及列表
2025-06-05 08:21:39 +00:00
hideMentionList()
}
}
/**
* 显示@提及列表
*
* 该函数根据当前查询文本过滤并显示@提及列表
* 1. 获取当前查询文本并转为小写
* 2. 过滤成员列表排除当前用户自己
* 3. 如果当前用户是群管理员添加"全体成员"选项
* 4. 根据过滤结果决定是否显示@提及列表
* 5. 重置选中项索引为第一项
*/
2025-06-05 08:21:39 +00:00
const showMentionList = () => {
// 获取当前查询文本并转为小写,用于不区分大小写的匹配
2025-06-05 08:21:39 +00:00
const query = currentMentionQuery.value.toLowerCase()
// 过滤成员列表,查找以查询文本开头的成员,并排除当前用户自己
mentionList.value = [{ id: 0, nickname: '全体成员', avatar: defAvatar, value: '全体成员' },...props.members].filter(member => {
return member.value.toLowerCase().startsWith(query) && member.id !== userStore.uid
})
// 只有当列表有内容时才显示
2025-06-05 08:21:39 +00:00
showMention.value = mentionList.value.length > 0
// 重置选中项索引为第一项
2025-06-05 08:21:39 +00:00
selectedMentionIndex.value = 0
}
/**
* 处理鼠标点击选择@提及成员
*
* 该函数处理用户通过鼠标点击选择@提及列表中的成员
* 1. 获取当前选择范围
* 2. 如果有有效的选择范围直接插入@提及元素
* 3. 如果没有有效的选择范围先聚焦编辑器然后在下一个渲染周期尝试插入
*
* @param {Object} member - 被选中的成员对象包含idvalue等属性
*/
const handleMentionSelectByMouse = (member) => {
// 获取当前选择范围
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
// 如果有有效的选择范围,直接插入@提及元素
insertMention(member, selection.getRangeAt(0).cloneRange());
} else {
// 如果没有有效的选择范围,先聚焦编辑器
editorRef.value?.focus();
// 在下一个渲染周期尝试获取新的选择范围并插入
nextTick(() => {
const newSelection = window.getSelection();
if (newSelection && newSelection.rangeCount > 0) {
insertMention(member, newSelection.getRangeAt(0).cloneRange());
}
});
}
};
/**
* 隐藏@提及列表
*
* 该函数负责隐藏@提及列表并清空相关状态
* 1. 设置显示标志为false
* 2. 清空成员列表
* 3. 清空当前查询文本
*/
2025-06-05 08:21:39 +00:00
const hideMentionList = () => {
// 隐藏@提及列表
2025-06-05 08:21:39 +00:00
showMention.value = false
// 清空成员列表
2025-06-05 08:21:39 +00:00
mentionList.value = []
// 清空当前查询文本
2025-06-05 08:21:39 +00:00
currentMentionQuery.value = ''
}
/**
* 更新@提及列表位置
*
* 该函数根据当前光标位置计算并更新@提及列表的显示位置
* 1. 获取当前选择范围的边界矩形
* 2. 获取编辑器的边界矩形
* 3. 计算@提及列表相对于编辑器的位置
*
* @param {Range} range - 当前选择范围
*/
2025-06-05 08:21:39 +00:00
const updateMentionPosition = (range) => {
// 获取当前选择范围的边界矩形
2025-06-05 08:21:39 +00:00
const rect = range.getBoundingClientRect()
// 获取编辑器的边界矩形
2025-06-05 08:21:39 +00:00
const editorRect = editorRef.value.getBoundingClientRect()
// 计算@提及列表相对于编辑器的位置
2025-06-05 08:21:39 +00:00
mentionPosition.value = {
// 垂直位置:光标底部位置 + 5px的偏移
2025-06-05 08:21:39 +00:00
top: rect.bottom - editorRect.top + 5,
// 水平位置:与光标左侧对齐
2025-06-05 08:21:39 +00:00
left: rect.left - editorRect.left
}
}
/**
* 插入@提及元素
*
* 该函数负责在编辑器中插入@提及元素
* 1. 创建一个不可编辑的span元素包含@符号和成员名称
* 2. 确定插入位置替换@符号及其后面的文本或在当前光标位置插入
* 3. 插入元素并更新选择范围
* 4. 触发输入事件并隐藏@提及列表
*
* @param {Object} member - @提及的成员对象
* @param {Range} clonedRange - 克隆的选择范围
*/
const insertMention = (member, clonedRange) => {
console.log('插入mention', member);
const selection = window.getSelection();
2025-06-11 08:54:54 +00:00
if (!clonedRange || !selection || !editorRef.value) return;
2025-06-11 08:54:54 +00:00
const range = clonedRange;
const editor = editorRef.value;
// 获取当前文本节点、偏移量和文本内容
const textNode = range.startContainer;
const offset = range.startOffset;
const textContent = textNode.nodeType === Node.TEXT_NODE ? textNode.textContent || '' : '';
2025-06-11 08:54:54 +00:00
// 查找@符号在文本中的位置
const atIndex = (textNode.nodeType === Node.TEXT_NODE && offset > 0) ? textContent.lastIndexOf('@', offset - 1) : -1;
// 创建@提及元素
const mentionSpan = document.createElement('span');
mentionSpan.className = 'mention';
mentionSpan.setAttribute('data-user-id', String(member.id));
2025-06-11 08:54:54 +00:00
mentionSpan.textContent = `@${member.value || member.nickname} `;
mentionSpan.contentEditable = 'false'; // 设置为不可编辑
// 根据@符号位置决定如何插入元素
if (atIndex !== -1 && textNode.nodeType === Node.TEXT_NODE) {
// 如果找到了@符号,替换@符号及其后面的文本
const parent = textNode.parentNode;
2025-06-11 08:54:54 +00:00
if (!parent) return;
// 设置范围从@符号开始到当前光标位置
range.setStart(textNode, atIndex);
range.setEnd(textNode, offset);
range.deleteContents(); // 删除范围内容
range.insertNode(mentionSpan); // 插入@提及元素
} else {
// 如果没有找到@符号,在当前光标位置插入
if (!range.collapsed) {
range.deleteContents(); // 如果有选中内容,先删除
}
range.insertNode(mentionSpan);
}
// 将光标移动到@提及元素后面
range.setStartAfter(mentionSpan);
2025-06-11 08:54:54 +00:00
range.collapse(true);
// 更新选择范围
selection.removeAllRanges();
selection.addRange(range);
// 保持编辑器焦点
2025-06-11 08:54:54 +00:00
editor.focus();
// 在下一个渲染周期触发输入事件并隐藏@提及列表
nextTick(() => {
2025-06-11 08:54:54 +00:00
handleInput({ target: editor });
hideMentionList();
});
};
2025-06-05 08:21:39 +00:00
/**
* 处理粘贴事件
*
* 该函数负责处理编辑器中的粘贴事件
* 1. 检测粘贴内容是否包含图片
* 2. 如果是图片创建临时URL并插入编辑器然后上传到服务器
* 3. 如果是文本直接插入到当前光标位置
*
* @param {ClipboardEvent} event - 粘贴事件对象
*/
2025-06-05 08:21:39 +00:00
const handlePaste = (event) => {
// 阻止默认粘贴行为,以便自定义处理
2025-06-11 08:54:54 +00:00
event.preventDefault();
if (!editorRef.value) return;
const clipboardData = event.clipboardData;
if (!clipboardData) return;
const items = clipboardData.items;
let imagePasted = false;
// 检查粘贴内容是否包含图片
if (items) {
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
2025-06-11 08:54:54 +00:00
const file = items[i].getAsFile();
if (file) {
2025-06-11 08:54:54 +00:00
imagePasted = true;
// 创建临时URL用于预览
const tempUrl = URL.createObjectURL(file);
2025-06-11 08:54:54 +00:00
const image = new Image();
image.src = tempUrl;
image.onload = () => {
// 创建表单数据用于上传
const form = new FormData();
2025-06-11 08:54:54 +00:00
form.append('file', file);
form.append('source', 'fonchain-chat');
form.append('urlParam', `width=${image.width}&height=${image.height}`);
// 先插入临时图片到编辑器并获取插入的img元素
const insertedImgElement = insertImage(tempUrl, image.width, image.height);
// 如果图片成功插入且有父节点wrapper span添加加载中样式
if (insertedImgElement && insertedImgElement.parentNode) {
insertedImgElement.parentNode.classList.add('image-upload-loading');
}
// 上传图片到服务器
2025-06-11 08:54:54 +00:00
uploadImg(form).then(({ code, data, message }) => {
// Query images inside the callback to get the most current list
const currentEditorImages = editorRef.value.querySelectorAll('img.editor-image');
2025-06-11 08:54:54 +00:00
if (code === 0 && data && data.ori_url) {
// 上传成功替换临时URL为服务器URL
for (let j = currentEditorImages.length - 1; j >= 0; j--) {
if (currentEditorImages[j].src === tempUrl) {
currentEditorImages[j].src = data.ori_url;
// 移除加载中样式
if (currentEditorImages[j].parentNode) {
currentEditorImages[j].parentNode.classList.remove('image-upload-loading');
}
break;
2025-06-11 08:54:54 +00:00
}
}
// 触发输入事件更新编辑器状态
handleInput({ target: editorRef.value });
} else {
// 上传失败,显示错误消息
2025-06-11 08:54:54 +00:00
window['$message'].error(message || '图片上传失败');
// 移除临时图片及其wrapper
for (let j = currentEditorImages.length - 1; j >= 0; j--) {
if (currentEditorImages[j].src === tempUrl) {
if (currentEditorImages[j].parentNode) {
currentEditorImages[j].parentNode.remove(); // Remove wrapper
} else {
currentEditorImages[j].remove(); // Fallback
}
2025-06-11 08:54:54 +00:00
break;
}
}
// 触发输入事件更新编辑器状态
2025-06-11 08:54:54 +00:00
handleInput({ target: editorRef.value });
}
URL.revokeObjectURL(tempUrl); // 在上传完成后成功或失败释放URL
2025-06-11 08:54:54 +00:00
}).catch(error => {
// 处理上传过程中的错误
2025-06-11 08:54:54 +00:00
console.error('Upload image error:', error);
window['$message'].error('图片上传过程中发生错误');
// Query images inside the callback
const currentEditorImages = editorRef.value.querySelectorAll('img.editor-image');
// 移除临时图片及其wrapper
for (let j = currentEditorImages.length - 1; j >= 0; j--) {
if (currentEditorImages[j].src === tempUrl) {
if (currentEditorImages[j].parentNode) {
currentEditorImages[j].parentNode.remove(); // Remove wrapper
} else {
currentEditorImages[j].remove(); // Fallback
2025-06-11 08:54:54 +00:00
}
break;
2025-06-11 08:54:54 +00:00
}
}
// 触发输入事件更新编辑器状态
2025-06-11 08:54:54 +00:00
handleInput({ target: editorRef.value });
URL.revokeObjectURL(tempUrl); // 在上传完成后成功或失败释放URL
2025-06-11 08:54:54 +00:00
});
};
image.onerror = () => {
// 图片加载失败
2025-06-11 08:54:54 +00:00
URL.revokeObjectURL(tempUrl);
window['$message'].error('无法加载粘贴的图片');
};
return; // 找到并处理了图片,不再继续处理
}
}
}
}
2025-06-11 08:54:54 +00:00
// 如果没有粘贴图片,处理文本内容
if (!imagePasted) {
2025-06-11 08:54:54 +00:00
const text = clipboardData.getData('text/plain') || '';
if (text) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
// 获取当前选择范围
2025-06-11 08:54:54 +00:00
const range = selection.getRangeAt(0);
// 删除选中内容
2025-06-11 08:54:54 +00:00
range.deleteContents();
// 创建文本节点并插入
2025-06-11 08:54:54 +00:00
const textNode = document.createTextNode(text);
range.insertNode(textNode);
// 将光标移动到插入文本后面
range.setStartAfter(textNode);
2025-06-11 08:54:54 +00:00
range.collapse(true);
// 更新选择范围
2025-06-11 08:54:54 +00:00
selection.removeAllRanges();
selection.addRange(range);
// 触发输入事件更新编辑器状态
2025-06-11 08:54:54 +00:00
handleInput({ target: editorRef.value });
}
2025-06-05 08:21:39 +00:00
}
}
2025-06-11 08:54:54 +00:00
};
/**
* 插入换行符
*
* 该函数负责在编辑器中插入换行符
* 1. 创建<br>元素并插入到当前选择范围
* 2. <br>元素后插入零宽空格确保光标可以正确定位
* 3. 更新选择范围将光标移动到换行符后
* 4. 触发输入事件更新编辑器状态
*
* @param {Range} range - 当前选择范围
*/
const insertLineBreak = (range) => {
const editor = editorRef.value;
if (!editor) return;
// 创建<br>元素
const br = document.createElement('br');
// 删除选中内容
range.deleteContents();
// 插入<br>元素
range.insertNode(br);
// 插入零宽空格,确保光标可以正确定位
// \u200B是零宽空格字符在视觉上不可见但可以作为光标位置
const nbsp = document.createTextNode('\u200B');
// 将范围移动到<br>元素后
range.setStartAfter(br);
// 插入零宽空格
range.insertNode(nbsp);
// 将范围移动到零宽空格后
range.setStartAfter(nbsp);
// 折叠范围,确保没有选中内容
range.collapse(true);
// 更新选择范围
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
// 保持编辑器焦点
editor.focus();
// 在下一个渲染周期触发输入事件
nextTick(() => {
handleInput({ target: editor });
});
};
/**
* 处理键盘事件
*
* 该函数负责处理编辑器中的键盘事件
* 1. 处理@提及列表的导航和选择
* 2. 处理删除键和退格键特别是删除@提及元素
* 3. 处理组合键插入换行符
* 4. 处理回车键发送消息
*
* @param {KeyboardEvent} event - 键盘事件对象
*/
2025-06-05 08:21:39 +00:00
const handleKeydown = (event) => {
2025-06-11 08:54:54 +00:00
const editor = editorRef.value;
if (!editor) return;
// 处理@提及列表的导航和选择
if (showMention.value) {
2025-06-11 08:54:54 +00:00
const mentionUl = document.querySelector('.mention-list ul');
let handled = false;
2025-06-05 08:21:39 +00:00
switch (event.key) {
case 'ArrowUp':
// 向上导航@提及列表
2025-06-11 08:54:54 +00:00
selectedMentionIndex.value = Math.max(0, selectedMentionIndex.value - 1);
if (mentionUl) {
// 确保选中项在可视区域内
2025-06-11 08:54:54 +00:00
const selectedItem = mentionUl.children[selectedMentionIndex.value];
if (selectedItem && selectedItem.offsetTop < mentionUl.scrollTop) {
mentionUl.scrollTop = selectedItem.offsetTop;
}
2025-06-11 08:54:54 +00:00
}
handled = true;
break;
2025-06-05 08:21:39 +00:00
case 'ArrowDown':
// 向下导航@提及列表
2025-06-11 08:54:54 +00:00
selectedMentionIndex.value = Math.min(mentionList.value.length - 1, selectedMentionIndex.value + 1);
if (mentionUl) {
// 确保选中项在可视区域内
2025-06-11 08:54:54 +00:00
const selectedItem = mentionUl.children[selectedMentionIndex.value];
if (selectedItem) {
const itemBottom = selectedItem.offsetTop + selectedItem.offsetHeight;
const listBottom = mentionUl.scrollTop + mentionUl.clientHeight;
if (itemBottom > listBottom) {
2025-06-11 08:54:54 +00:00
mentionUl.scrollTop = itemBottom - mentionUl.clientHeight;
}
}
2025-06-11 08:54:54 +00:00
}
handled = true;
break;
2025-06-05 08:21:39 +00:00
case 'Enter':
case 'Tab':
// 选择当前高亮的@提及项
const selectedMember = mentionList.value[selectedMentionIndex.value];
if (selectedMember) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
insertMention(selectedMember, selection.getRangeAt(0).cloneRange());
}
2025-06-05 08:21:39 +00:00
}
2025-06-11 08:54:54 +00:00
handled = true;
break;
2025-06-05 08:21:39 +00:00
case 'Escape':
// 关闭@提及列表
2025-06-11 08:54:54 +00:00
hideMentionList();
handled = true;
break;
}
if (handled) {
// 阻止默认行为,避免与编辑器冲突
2025-06-11 08:54:54 +00:00
event.preventDefault();
return;
2025-06-05 08:21:39 +00:00
}
}
2025-06-11 08:54:54 +00:00
// 处理删除键和退格键,特别是删除@提及元素
if (event.key === 'Backspace' || event.key === 'Delete') {
2025-06-11 08:54:54 +00:00
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return;
const range = selection.getRangeAt(0);
if (range.collapsed) {
// 确定要检查的节点
2025-06-11 08:54:54 +00:00
let nodeToCheck = null;
let positionRelativeToCheck = '';
2025-06-11 08:54:54 +00:00
const container = range.startContainer;
const offset = range.startOffset;
if (event.key === 'Backspace') {
// 退格键:检查光标前的节点
if (offset === 0) {
// 如果光标在文本开头,检查前一个兄弟节点
nodeToCheck = container.previousSibling;
2025-06-11 08:54:54 +00:00
positionRelativeToCheck = 'before';
} else if (container.nodeType === Node.ELEMENT_NODE && offset > 0) {
// 如果光标在元素内部,检查前一个子节点
nodeToCheck = container.childNodes[offset - 1];
2025-06-11 08:54:54 +00:00
positionRelativeToCheck = 'before';
}
} else if (event.key === 'Delete') {
// 删除键:检查光标后的节点
if (container.nodeType === Node.TEXT_NODE && offset === container.textContent.length) {
// 如果光标在文本末尾,检查下一个兄弟节点
nodeToCheck = container.nextSibling;
2025-06-11 08:54:54 +00:00
positionRelativeToCheck = 'after';
} else if (container.nodeType === Node.ELEMENT_NODE && offset < container.childNodes.length) {
// 如果光标在元素内部,检查后一个子节点
nodeToCheck = container.childNodes[offset];
2025-06-11 08:54:54 +00:00
positionRelativeToCheck = 'after';
}
}
// 如果要检查的节点是@提及元素,则删除它
if (nodeToCheck && nodeToCheck.nodeType === Node.ELEMENT_NODE && nodeToCheck.classList.contains('mention')) {
event.preventDefault(); // 阻止默认删除行为
2025-06-11 08:54:54 +00:00
const parent = nodeToCheck.parentNode;
parent.removeChild(nodeToCheck); // 移除@提及元素
// 触发输入事件更新编辑器状态
2025-06-11 08:54:54 +00:00
handleInput({ target: editor });
return;
}
}
}
2025-06-11 08:54:54 +00:00
// 处理组合键插入换行符Ctrl+Enter、Shift+Enter、Meta+Enter
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey || event.shiftKey)) {
event.preventDefault(); // 阻止默认回车行为
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
// 如果没有选择范围,先聚焦编辑器
editor.focus();
nextTick(() => {
// 在下一个渲染周期尝试获取新的选择范围
const newSelection = window.getSelection();
if (newSelection && newSelection.rangeCount > 0) {
insertLineBreak(newSelection.getRangeAt(0));
}
});
return;
}
// 插入换行符
insertLineBreak(selection.getRangeAt(0));
return;
}
// 处理回车键发送消息
if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
event.preventDefault(); // 阻止默认回车行为
// 解析编辑器内容
2025-06-11 08:54:54 +00:00
const messageData = parseEditorContent();
// 检查消息是否为空
const isEmptyMessage = messageData.items.length === 0 ||
2025-06-11 08:54:54 +00:00
(messageData.items.length === 1 &&
messageData.items[0].type === 1 &&
!messageData.items[0].content.trimEnd());
if (isEmptyMessage) {
// 如果消息为空但编辑器不为空,清空编辑器
2025-06-11 08:54:54 +00:00
if (editor.innerHTML.trim() !== '' && editor.innerHTML.trim() !== '<br>') {
clearEditor();
}
2025-06-11 08:54:54 +00:00
return;
}
2025-06-11 08:54:54 +00:00
// 检查引用元素是否存在,如果不存在但引用数据存在,清空引用数据
const quoteElement = editor.querySelector('.editor-quote');
if (!quoteElement && quoteData.value) {
quoteData.value = null;
2025-06-05 08:21:39 +00:00
}
// 发送消息
2025-06-11 08:54:54 +00:00
sendMessage();
2025-06-05 08:21:39 +00:00
}
2025-06-11 08:54:54 +00:00
};
2025-06-05 08:21:39 +00:00
/**
* 发送消息函数
*
* 该函数负责处理编辑器内容的发送
* 1. 解析编辑器内容获取结构化数据
* 2. 清理和格式化文本内容
* 3. 根据内容类型纯文本单图片单文件触发不同的事件
* 4. 发送后清空编辑器
*/
2025-06-05 08:21:39 +00:00
const sendMessage = () => {
// 获取编辑器DOM引用
2025-06-11 08:54:54 +00:00
const editor = editorRef.value;
if (!editor) return;
// 解析编辑器内容,获取结构化数据
2025-06-11 08:54:54 +00:00
const parsedData = parseEditorContent();
/**
* 清理不可见字符
* 移除零宽字符和其他不可见字符
* @param {string} text - 需要清理的文本
* @returns {string} 清理后的文本
*/
2025-06-11 08:54:54 +00:00
const cleanInvisibleChars = (text) => {
return text ? String(text).replace(/[\u200B-\u200D\uFEFF]/g, '') : '';
};
// 处理和过滤消息项
2025-06-11 08:54:54 +00:00
let finalItems = [];
if (parsedData && parsedData.items) {
// 处理每个消息项
2025-06-11 08:54:54 +00:00
finalItems = parsedData.items.map(item => {
// 处理文本类型的消息项
if (item.type === 1 && typeof item.content === 'string') {
let content = cleanInvisibleChars(item.content);
// 将HTML换行标签转换为文本换行符并去除首尾空白
2025-06-11 08:54:54 +00:00
content = content.replace(/<br\s*\/?>/gi, '\n').trim();
return { ...item, content };
}
2025-06-11 08:54:54 +00:00
return item;
}).filter(item => {
// 过滤掉空内容的消息项(除非有@提及)
2025-06-11 08:54:54 +00:00
if (item.type === 1 && !item.content && !(parsedData.mentionUids && parsedData.mentionUids.length > 0)) return false;
// 过滤掉没有内容的图片项
if (item.type === 3 && !item.content) return false;
// 过滤掉没有内容的文件项
if (item.type === 4 && !item.content) return false;
return true;
2025-06-11 08:54:54 +00:00
});
}
// 检查是否有实际内容(文本、图片或文件)
2025-06-11 08:54:54 +00:00
const hasActualContent = finalItems.some(item => (item.type === 1 && item.content) || item.type === 3 || item.type === 4);
// 如果没有实际内容且没有@提及和引用,则不发送
2025-06-11 08:54:54 +00:00
if (!hasActualContent && !(parsedData.mentionUids && parsedData.mentionUids.length > 0) && !parsedData.quoteId) {
// 如果编辑器不为空,则清空编辑器
2025-06-11 08:54:54 +00:00
if (editor.innerHTML.trim() !== '' && editor.innerHTML.trim() !== '<br>') {
clearEditor();
}
2025-06-11 08:54:54 +00:00
return;
}
// 构建要发送的消息对象
2025-06-11 08:54:54 +00:00
const messageToSend = {
// 消息项数组,如果为空则添加一个空文本项
2025-06-11 08:54:54 +00:00
items: finalItems.length > 0 ? finalItems : [{ type: 1, content: '' }],
// @提及的用户ID数组
2025-06-11 08:54:54 +00:00
mentionUids: parsedData.mentionUids || [],
// @提及的用户信息数组
2025-06-11 08:54:54 +00:00
mentions: (parsedData.mentionUids || []).map(uid => {
const member = mentionList.value.find(m => m.id === uid);
return { atid: uid, name: member ? member.nickname : '' };
}),
// 引用的消息ID
2025-06-11 08:54:54 +00:00
quoteId: parsedData.quoteId || null
};
// 处理引用数据
2025-06-11 08:54:54 +00:00
if (messageToSend.quoteId && quoteData.value && quoteData.value.id === messageToSend.quoteId) {
// 如果引用ID与当前引用数据匹配添加完整引用数据
2025-06-11 08:54:54 +00:00
messageToSend.quote = { ...quoteData.value };
} else if (messageToSend.quoteId) {
// 如果有引用ID但没有匹配的引用数据这里似乎缺少处理逻辑
2025-06-11 08:54:54 +00:00
} else {
// 如果没有引用ID删除引用数据
delete messageToSend.quote;
2025-06-11 08:54:54 +00:00
}
messageToSend.items.forEach(item => {
if (item.type === 1 && cleanInvisibleChars(item.content.trimEnd())) {
const data = {
items: [{
content: cleanInvisibleChars(item.content),
type: 1
}],
mentionUids: messageToSend.mentionUids,
mentions: messageToSend.mentionUids.map(uid => {
return {
atid: uid,
name: mentionList.value.find(member => member.id === uid)?.nickname || ''
}
}),
quoteId: messageToSend.quoteId,
}
emit(
'editor-event',
emitCall('text_event', data)
)
} else if (item.type === 3) {
const data = {
height: 0,
width: 0,
size: 10000,
url: item.content,
}
emit(
'editor-event',
emitCall('image_event', data)
)
} else if (item.type === 4) {
}
})
// 发送后清空编辑器
2025-06-11 08:54:54 +00:00
clearEditor();
2025-06-05 08:21:39 +00:00
}
/**
* 解析编辑器内容函数
*
* 该函数负责将编辑器的HTML内容解析为结构化数据
* 1. 提取文本内容图片文件和@提及信息
* 2. 处理引用元素
* 3. 将不同类型的内容转换为统一的数据结构
*
* @returns {Object} 包含items内容项数组mentionUids@提及用户ID数组和quoteId引用消息ID的对象
*/
2025-06-05 08:21:39 +00:00
const parseEditorContent = () => {
// 初始化内容项数组
2025-06-11 08:54:54 +00:00
const items = [];
// 使用Set存储@提及的用户ID确保唯一性
2025-06-11 08:54:54 +00:00
const mentionUids = new Set();
// 初始化引用消息ID
2025-06-11 08:54:54 +00:00
let parsedQuoteId = null;
// 获取编辑器DOM节点
2025-06-11 08:54:54 +00:00
const editorNode = editorRef.value;
// 如果编辑器不存在,返回空内容
2025-06-11 08:54:54 +00:00
if (!editorNode) {
return { items: [{ type: 1, content: '' }], mentionUids: [], quoteId: null };
}
// 创建临时div用于解析HTML内容
2025-06-11 08:54:54 +00:00
const tempDiv = document.createElement('div');
tempDiv.innerHTML = editorHtml.value;
2025-06-11 08:54:54 +00:00
// 查找并处理引用元素
2025-06-11 08:54:54 +00:00
const quoteElement = tempDiv.querySelector('.editor-quote');
if (quoteElement && quoteData.value && quoteData.value.id) {
// 保存引用消息ID
2025-06-11 08:54:54 +00:00
parsedQuoteId = quoteData.value.id;
// 从临时div中移除引用元素避免重复处理
quoteElement.remove();
2025-06-11 08:54:54 +00:00
}
// 当前文本缓冲区,用于累积连续的文本内容
2025-06-11 08:54:54 +00:00
let currentTextBuffer = '';
/**
* 将当前文本缓冲区内容添加到items数组中如果有内容
* 然后清空缓冲区
*/
2025-06-11 08:54:54 +00:00
const flushTextBufferIfNeeded = () => {
if (currentTextBuffer) {
items.push({ type: 1, content: currentTextBuffer });
}
currentTextBuffer = '';
};
/**
* 递归处理DOM节点及其子节点
* 根据节点类型和标签名提取不同类型的内容
*
* @param {Node} node - 要处理的DOM节点
*/
2025-06-11 08:54:54 +00:00
const processNodeRecursively = (node) => {
// 处理文本节点
2025-06-05 08:21:39 +00:00
if (node.nodeType === Node.TEXT_NODE) {
// 将文本内容添加到缓冲区
2025-06-11 08:54:54 +00:00
currentTextBuffer += node.textContent;
return;
}
// 忽略非元素节点
if (node.nodeType !== Node.ELEMENT_NODE) return;
// 根据元素标签名处理不同类型的内容
2025-06-11 08:54:54 +00:00
switch (node.tagName) {
case 'BR':
// 将换行标签转换为换行符
currentTextBuffer += '\n';
2025-06-11 08:54:54 +00:00
break;
case 'IMG':
// 处理图片元素
2025-06-11 08:54:54 +00:00
flushTextBufferIfNeeded();
const src = node.getAttribute('src');
const alt = node.getAttribute('alt');
const isEmojiPic = node.classList.contains('editor-emoji');
const isTextEmojiPlaceholder = node.classList.contains('emoji');
2025-06-11 08:54:54 +00:00
if (isTextEmojiPlaceholder && alt) {
// 如果是文本表情占位符将其alt属性表情文本添加到缓冲区
currentTextBuffer += alt;
2025-06-11 08:54:54 +00:00
} else if (src) {
// 如果是普通图片或图片表情,添加为图片类型的内容项
2025-06-11 08:54:54 +00:00
items.push({
type: 3, // 图片类型
content: src, // 图片URL
isEmoji: isEmojiPic, // 是否为表情图片
// 获取图片原始宽度和高度
2025-06-11 08:54:54 +00:00
width: node.getAttribute('data-original-width') || node.width || null,
height: node.getAttribute('data-original-height') || node.height || null,
});
}
break;
default:
// 处理其他元素
2025-06-11 08:54:54 +00:00
if (node.classList.contains('mention')) {
// 处理@提及元素
2025-06-11 08:54:54 +00:00
const userId = node.getAttribute('data-user-id');
if (userId) {
// 将用户ID添加到Set中
2025-06-11 08:54:54 +00:00
mentionUids.add(Number(userId));
}
// 将@提及文本添加到缓冲区
currentTextBuffer += node.textContent || '';
2025-06-11 08:54:54 +00:00
} else if (node.classList.contains('editor-file')) {
// 处理文件元素
2025-06-11 08:54:54 +00:00
flushTextBufferIfNeeded();
const fileUrl = node.getAttribute('data-url');
const fileName = node.getAttribute('data-name');
const fileSize = node.getAttribute('data-size-raw') || node.getAttribute('data-size') || 0;
if (fileUrl && fileName) {
// 添加为文件类型的内容项
2025-06-11 08:54:54 +00:00
items.push({
type: 4, // 文件类型
content: fileUrl, // 文件URL
name: fileName, // 文件名
size: parseInt(fileSize, 10), // 文件大小
2025-06-11 08:54:54 +00:00
});
}
} else if (node.childNodes && node.childNodes.length > 0) {
// 如果元素有子节点,递归处理所有子节点
2025-06-11 08:54:54 +00:00
Array.from(node.childNodes).forEach(processNodeRecursively);
} else if (node.textContent) {
// 如果元素有文本内容但没有子节点,将文本内容添加到缓冲区
2025-06-11 08:54:54 +00:00
currentTextBuffer += node.textContent;
}
break;
}
};
// 处理临时div中的所有子节点
2025-06-11 08:54:54 +00:00
Array.from(tempDiv.childNodes).forEach(processNodeRecursively);
// 处理最后的文本缓冲区
flushTextBufferIfNeeded();
2025-06-11 08:54:54 +00:00
// 返回解析结果
return {
// 内容项数组,如果为空则添加一个空文本项
2025-06-05 08:21:39 +00:00
items: items.length > 0 ? items : [{ type: 1, content: '' }],
// 将Set转换为数组
2025-06-11 08:54:54 +00:00
mentionUids: Array.from(mentionUids),
// 引用消息ID
2025-06-11 08:54:54 +00:00
quoteId: parsedQuoteId
};
};
2025-06-05 08:21:39 +00:00
/**
* 清空编辑器函数
*
* 该函数负责清空编辑器内容和相关状态
* 1. 清空编辑器DOM内容和相关变量
* 2. 清空引用数据
* 3. 隐藏@提及列表
* 4. 触发清空事件
* 5. 重新聚焦编辑器
*/
2025-06-05 08:21:39 +00:00
const clearEditor = () => {
// 清空编辑器DOM内容
2025-06-05 08:21:39 +00:00
if (editorRef.value) {
2025-06-11 08:54:54 +00:00
editorRef.value.innerHTML = '';
2025-06-05 08:21:39 +00:00
}
// 清空编辑器内容变量
2025-06-11 08:54:54 +00:00
editorContent.value = '';
editorHtml.value = '';
// 清空引用数据
2025-06-11 08:54:54 +00:00
quoteData.value = null;
// 隐藏@提及列表
hideMentionList();
// 移除可能存在的引用元素
2025-06-11 08:54:54 +00:00
const existingQuoteElement = editorRef.value ? editorRef.value.querySelector('.editor-quote') : null;
if (existingQuoteElement) {
existingQuoteElement.remove();
}
// 触发输入事件,更新编辑器状态
handleInput();
2025-06-11 08:54:54 +00:00
// 触发清空事件
2025-06-05 08:21:39 +00:00
emit('editor-event', {
event: 'clear_event',
2025-06-05 08:21:39 +00:00
data: ''
2025-06-11 08:54:54 +00:00
});
// 重新聚焦编辑器
2025-06-11 08:54:54 +00:00
if (editorRef.value) {
nextTick(() => {
// 聚焦编辑器
2025-06-11 08:54:54 +00:00
editorRef.value.focus();
// 如果编辑器内容只有一个<br>标签,清空它
if (editorRef.value && editorRef.value.innerHTML.toLowerCase() === '<br>') {
2025-06-11 08:54:54 +00:00
editorRef.value.innerHTML = '';
}
});
}
};
2025-06-05 08:21:39 +00:00
2025-06-11 08:54:54 +00:00
const insertImage = (fileOrSrc, isUploaded = false, uploadedUrl = '') => {
if (!editorRef.value) return;
const img = document.createElement('img');
img.className = 'editor-image';
img.alt = '图片';
img.style.maxWidth = '200px';
img.style.maxHeight = '200px';
2025-06-11 08:54:54 +00:00
img.style.borderRadius = '4px';
img.style.objectFit = 'contain';
img.style.margin = '5px';
// 创建包装器
const wrapper = document.createElement('span');
wrapper.className = 'editor-image-wrapper';
wrapper.style.position = 'relative';
wrapper.style.display = 'inline-block';
wrapper.appendChild(img);
2025-06-11 08:54:54 +00:00
const setupAndInsert = (imageUrl, naturalWidth, naturalHeight) => {
img.src = imageUrl;
if (naturalWidth) img.setAttribute('data-original-width', naturalWidth);
if (naturalHeight) img.setAttribute('data-original-height', naturalHeight);
if (isUploaded && uploadedUrl) {
img.setAttribute('data-uploaded-url', uploadedUrl);
img.setAttribute('data-status', 'uploaded');
} else {
img.setAttribute('data-status', 'local-preview');
}
const selection = window.getSelection();
let range;
if (selection && selection.rangeCount > 0) {
range = selection.getRangeAt(0);
if (!editorRef.value.contains(range.commonAncestorContainer)) {
editorRef.value.focus();
range = document.createRange();
range.selectNodeContents(editorRef.value);
range.collapse(false);
2025-06-11 08:54:54 +00:00
}
} else {
editorRef.value.focus();
range = document.createRange();
range.selectNodeContents(editorRef.value);
range.collapse(false);
2025-06-11 08:54:54 +00:00
}
range.deleteContents();
range.insertNode(wrapper);
const spaceNode = document.createTextNode('\u00A0');
2025-06-11 08:54:54 +00:00
range.insertNode(spaceNode);
range.setStartAfter(spaceNode);
range.collapse(false);
2025-06-11 08:54:54 +00:00
selection.removeAllRanges();
selection.addRange(range);
editorRef.value.focus();
handleInput();
2025-06-11 08:54:54 +00:00
};
if (typeof fileOrSrc === 'string') {
2025-06-11 08:54:54 +00:00
const tempImageForSize = new Image();
tempImageForSize.onload = () => {
setupAndInsert(fileOrSrc, tempImageForSize.naturalWidth, tempImageForSize.naturalHeight);
};
tempImageForSize.onerror = () => {
console.warn('Failed to load image from URL for size calculation:', fileOrSrc);
setupAndInsert(fileOrSrc);
2025-06-11 08:54:54 +00:00
};
tempImageForSize.src = fileOrSrc;
} else if (fileOrSrc instanceof File && fileOrSrc.type.startsWith('image/')) {
2025-06-11 08:54:54 +00:00
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target.result;
const tempImageForSize = new Image();
tempImageForSize.onload = () => {
setupAndInsert(dataUrl, tempImageForSize.naturalWidth, tempImageForSize.naturalHeight);
};
tempImageForSize.onerror = () => {
console.warn('Failed to load image from FileReader for size calculation.');
setupAndInsert(dataUrl);
2025-06-11 08:54:54 +00:00
};
tempImageForSize.src = dataUrl;
};
reader.onerror = (error) => {
console.error('FileReader error:', error);
};
reader.readAsDataURL(fileOrSrc);
} else {
console.warn('insertImage: Invalid file object or URL provided.');
}
return img; // 返回 img 元素以便外部访问
2025-06-11 08:54:54 +00:00
};
2025-06-05 08:21:39 +00:00
2025-06-05 08:21:39 +00:00
const formatFileSize = (size) => {
if (size < 1024) {
return size + ' B'
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB'
} else if (size < 1024 * 1024 * 1024) {
return (size / (1024 * 1024)).toFixed(2) + ' MB'
} else {
return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
}
}
/**
* 处理图片上传并发送
*
* @param {Event} event - 文件选择事件
*
* 处理从工具栏上传图片的逻辑
* 1. 获取选择的图片文件
* 2. 上传图片到服务器
* 3. 更新编辑器中的临时图片
* 4. 触发image_event事件
* 5. 处理上传失败情况
*/
2025-06-11 08:54:54 +00:00
const onUploadSendImg = async (event) => {
// 检查事件对象和文件列表是否存在
2025-06-11 08:54:54 +00:00
if (!event.target || !event.target.files) return;
const files = event.target.files;
// 遍历所有选择的文件
2025-06-11 08:54:54 +00:00
for (const file of files) {
// 验证文件类型是否为图片
2025-06-11 08:54:54 +00:00
if (!file.type.startsWith('image/')) {
console.warn('Invalid file type for image upload:', file.type);
continue;
}
// 注释掉的代码:不再在编辑器中插入图片预览
// insertImage(file, false);
2025-06-11 08:54:54 +00:00
// 创建FormData对象用于上传
2025-06-11 08:54:54 +00:00
const formData = new FormData();
formData.append('file', file);
formData.append('source', 'fonchain-chat'); // 指定来源
2025-06-11 08:54:54 +00:00
try {
// 调用上传API
2025-06-11 08:54:54 +00:00
const res = await uploadImg(formData);
// 上传成功且返回有效URL
2025-06-11 08:54:54 +00:00
if (res && res.status === 0 && res.data && res.data.ori_url) {
// 查找编辑器中的临时预览图片
2025-06-11 08:54:54 +00:00
const previewImages = editorRef.value.querySelectorAll('img[data-status="local-preview"][src^="data:image"]:not([data-uploaded-url])');
if (previewImages.length > 0) {
// 获取最后一个预览图片(通常是刚刚插入的)
2025-06-11 08:54:54 +00:00
const lastPreviewImage = previewImages[previewImages.length - 1];
if (lastPreviewImage && lastPreviewImage.src.startsWith('data:image')) {
// 更新图片属性为上传后的URL和状态
2025-06-11 08:54:54 +00:00
lastPreviewImage.src = res.data.ori_url;
lastPreviewImage.setAttribute('data-uploaded-url', res.data.ori_url);
lastPreviewImage.setAttribute('data-status', 'uploaded');
if (res.data.width) lastPreviewImage.setAttribute('data-original-width', res.data.width);
if (res.data.height) lastPreviewImage.setAttribute('data-original-height', res.data.height);
handleInput(); // 触发输入事件更新编辑器状态
2025-06-11 08:54:54 +00:00
}
}
// 触发图片事件,通知父组件处理上传的图片
2025-06-11 08:54:54 +00:00
emit('editor-event', emitCall('image_event', {
url: res.data.ori_url,
width: res.data.width || 0,
height: res.data.height || 0,
size: 10000
2025-06-11 08:54:54 +00:00
}));
} else {
// 上传失败或返回无效响应
2025-06-11 08:54:54 +00:00
console.error('Image upload failed or received invalid response:', res);
// 查找并标记失败的预览图片
2025-06-11 08:54:54 +00:00
const previewImages = editorRef.value.querySelectorAll('img[data-status="local-preview"][src^="data:image"]:not([data-uploaded-url])');
if (previewImages.length > 0) {
const lastPreviewImage = previewImages[previewImages.length -1];
if(lastPreviewImage) {
// 添加红色边框和提示,指示上传失败
2025-06-11 08:54:54 +00:00
lastPreviewImage.style.border = '2px dashed red';
lastPreviewImage.title = 'Upload failed';
}
}
}
} catch (error) {
// 捕获上传过程中的错误
2025-06-11 08:54:54 +00:00
console.error('Error during image upload process:', error);
// 查找并标记错误的预览图片
2025-06-11 08:54:54 +00:00
const previewImages = editorRef.value.querySelectorAll('img[data-status="local-preview"][src^="data:image"]:not([data-uploaded-url])');
if (previewImages.length > 0) {
const lastPreviewImage = previewImages[previewImages.length -1];
if(lastPreviewImage) {
// 添加红色边框和提示,指示上传错误
2025-06-11 08:54:54 +00:00
lastPreviewImage.style.border = '2px dashed red';
lastPreviewImage.title = 'Upload error';
}
}
}
}
// 清空文件输入框,允许选择相同文件再次上传
if (event.target) event.target.value = '';
2025-06-11 08:54:54 +00:00
};
/**
* 处理文件上传事件
*
* @param {Event} e - 文件选择事件
*
* 根据上传文件类型触发不同的事件
* - 图片文件触发image_event事件
* - 视频文件触发video_event事件
* - 其他文件触发file_event事件
*/
2025-06-11 08:54:54 +00:00
async function onUploadFile(e) {
// 检查事件对象和文件列表是否存在且不为空
2025-06-11 08:54:54 +00:00
if (!e.target || !e.target.files || e.target.files.length === 0) return;
const file = e.target.files[0];
// 清空文件输入框,允许再次选择相同文件
2025-06-11 08:54:54 +00:00
e.target.value = null;
// 获取文件类型并初始化事件名称
2025-06-11 08:54:54 +00:00
const fileType = file.type;
let eventName = '';
// 根据文件类型确定事件名称并触发相应事件
2025-06-11 08:54:54 +00:00
if (fileType.startsWith('image/')) {
eventName = 'image_event';
2025-06-11 08:54:54 +00:00
emit('editor-event', emitCall(eventName, file));
} else if (fileType.startsWith('video/')) {
eventName = 'video_event';
emit('editor-event', emitCall(eventName, file));
2025-06-05 08:21:39 +00:00
} else {
2025-06-11 08:54:54 +00:00
eventName = 'file_event';
2025-06-11 08:54:54 +00:00
emit('editor-event', emitCall(eventName, file));
2025-06-05 08:21:39 +00:00
}
}
/**
* 处理表情符号选择事件
*
* @param {Object} emoji - 表情符号对象包含类型图片URL等信息
*
* 根据表情符号类型执行不同的插入操作
* - text/emoji类型插入文本表情
* - image类型插入图片表情
* - 类型为1根据是否有图片URL决定插入图片或文本表情
* - 其他类型触发emoticon_event事件
*/
2025-06-05 08:21:39 +00:00
const onEmoticonEvent = (emoji) => {
// 关闭表情选择器面板
2025-06-05 08:21:39 +00:00
emoticonRef.value?.setShow(false)
// 根据表情类型执行不同操作
switch (emoji.type) {
case 'text':
case 'emoji':
// 插入文本表情(如:😊)
2025-06-05 08:21:39 +00:00
insertTextEmoji(emoji.value)
break
case 'image':
// 插入图片表情带图片URL的表情
insertImageEmoji(emoji.img, emoji.value)
break
case 1:
// 兼容旧版表情格式,根据是否有图片决定插入方式
emoji.img ? insertImageEmoji(emoji.img, emoji.value) : insertTextEmoji(emoji.value)
break
default:
// 其他类型表情,触发事件让父组件处理
emit('editor-event', {
event: 'emoticon_event',
data: emoji.value || emoji.id
})
break
2025-06-05 08:21:39 +00:00
}
}
/**
* 在编辑器中插入文本表情
*
* @param {string} emojiText - 要插入的表情文本😊
*
* 该函数在当前光标位置插入文本表情
* 1. 获取当前选区
* 2. 如果选区不在编辑器内则创建新选区在编辑器末尾
* 3. 删除选中内容如果有
* 4. 插入表情文本
* 5. 将光标移动到插入的表情后面
* 6. 触发输入事件更新编辑器状态
*/
2025-06-05 08:21:39 +00:00
const insertTextEmoji = (emojiText) => {
// 验证编辑器引用和表情文本
2025-06-11 08:54:54 +00:00
if (!editorRef.value || typeof emojiText !== 'string') return;
2025-06-05 08:21:39 +00:00
2025-06-11 08:54:54 +00:00
const editor = editorRef.value;
editor.focus(); // 确保编辑器获得焦点
2025-06-05 08:21:39 +00:00
// 获取当前选区
2025-06-11 08:54:54 +00:00
const selection = window.getSelection();
let range;
2025-06-05 08:21:39 +00:00
2025-06-11 08:54:54 +00:00
if (selection && selection.rangeCount > 0) {
range = selection.getRangeAt(0);
// 检查选区是否在编辑器内
2025-06-11 08:54:54 +00:00
if (!editor.contains(range.commonAncestorContainer)) {
// 如果不在编辑器内,创建新选区在编辑器末尾
2025-06-11 08:54:54 +00:00
range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false); // 折叠到末尾
2025-06-11 08:54:54 +00:00
}
} else {
// 如果没有选区,创建新选区在编辑器末尾
2025-06-11 08:54:54 +00:00
range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false); // 折叠到末尾
2025-06-11 08:54:54 +00:00
}
2025-06-05 08:21:39 +00:00
// 删除选中内容(如果有)
range.deleteContents();
2025-06-05 08:21:39 +00:00
// 创建文本节点并插入
2025-06-11 08:54:54 +00:00
const textNode = document.createTextNode(emojiText);
range.insertNode(textNode);
// 将光标移动到插入的表情后面
2025-06-11 08:54:54 +00:00
range.setStartAfter(textNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
2025-06-11 08:54:54 +00:00
// 触发输入事件更新编辑器状态
handleInput();
2025-06-11 08:54:54 +00:00
};
2025-06-05 08:21:39 +00:00
/**
* 在编辑器中插入图片表情
*
* @param {string} imgSrc - 表情图片的URL
* @param {string} altText - 图片的替代文本
*
* 该函数在当前光标位置插入图片表情
* 1. 获取当前选区
* 2. 如果选区不在编辑器内则创建新选区在编辑器末尾
* 3. 删除选中内容如果有
* 4. 创建并插入图片元素
* 5. 在图片后插入空格
* 6. 将光标移动到插入的空格后面
* 7. 触发输入事件更新编辑器状态
*/
2025-06-05 08:21:39 +00:00
const insertImageEmoji = (imgSrc, altText) => {
// 验证编辑器引用和图片URL
2025-06-11 08:54:54 +00:00
if (!editorRef.value || !imgSrc) return;
2025-06-05 08:21:39 +00:00
2025-06-11 08:54:54 +00:00
const editor = editorRef.value;
editor.focus(); // 确保编辑器获得焦点
2025-06-05 08:21:39 +00:00
// 获取当前选区
2025-06-11 08:54:54 +00:00
const selection = window.getSelection();
let range;
2025-06-05 08:21:39 +00:00
2025-06-11 08:54:54 +00:00
if (selection && selection.rangeCount > 0) {
range = selection.getRangeAt(0);
// 检查选区是否在编辑器内
2025-06-11 08:54:54 +00:00
if (!editor.contains(range.commonAncestorContainer)) {
// 如果不在编辑器内,创建新选区在编辑器末尾
2025-06-11 08:54:54 +00:00
range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false); // 折叠到末尾
2025-06-11 08:54:54 +00:00
}
} else {
// 如果没有选区,创建新选区在编辑器末尾
2025-06-11 08:54:54 +00:00
range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false); // 折叠到末尾
2025-06-11 08:54:54 +00:00
}
2025-06-05 08:21:39 +00:00
// 删除选中内容(如果有)
range.deleteContents();
2025-06-05 08:21:39 +00:00
// 创建图片元素
2025-06-11 08:54:54 +00:00
const img = document.createElement('img');
img.src = imgSrc;
img.alt = altText || 'emoji'; // 设置替代文本,默认为'emoji'
img.className = 'editor-emoji'; // 添加样式类
img.setAttribute('data-role', 'emoji'); // 设置角色属性,用于标识
2025-06-05 08:21:39 +00:00
// 插入图片
2025-06-11 08:54:54 +00:00
range.insertNode(img);
// 在图片后插入空格(非断行空格)
const spaceNode = document.createTextNode('\u00A0');
2025-06-11 08:54:54 +00:00
range.setStartAfter(img);
range.collapse(true);
range.insertNode(spaceNode);
// 将光标移动到空格后面
2025-06-11 08:54:54 +00:00
range.setStartAfter(spaceNode);
range.collapse(true);
// 更新选区
2025-06-11 08:54:54 +00:00
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
// 触发输入事件更新编辑器状态
handleInput();
2025-06-11 08:54:54 +00:00
};
2025-06-05 08:21:39 +00:00
/**
* 处理@提及事件在编辑器中插入@提及元素
*
* @param {Object} data - 提及的用户数据包含用户ID名称等信息
*
* 该函数在当前光标位置插入@提及元素
* 1. 获取当前选区
* 2. 如果选区不在编辑器内则创建新选区在编辑器末尾
* 3. 更新选区
* 4. 调用insertMention函数插入@提及元素
*/
const onSubscribeMention = async (data) => {
// 验证编辑器引用和用户数据
2025-06-11 08:54:54 +00:00
if (!editorRef.value || !data) return;
const editorNode = editorRef.value;
// 确保编辑器获得焦点
editorNode.focus();
// 等待下一个DOM更新周期确保焦点设置生效
await nextTick();
// 获取当前选区
let selection = window.getSelection();
2025-06-11 08:54:54 +00:00
let range;
if (selection && selection.rangeCount > 0) {
range = selection.getRangeAt(0);
// 检查选区是否在编辑器内
2025-06-11 08:54:54 +00:00
if (!editorNode.contains(range.commonAncestorContainer)) {
// 如果不在编辑器内,创建新选区在编辑器末尾
2025-06-11 08:54:54 +00:00
range = document.createRange();
range.selectNodeContents(editorNode);
range.collapse(false); // 折叠到末尾
}
2025-06-11 08:54:54 +00:00
} else {
// 如果没有选区,创建新选区在编辑器末尾
2025-06-11 08:54:54 +00:00
range = document.createRange();
range.selectNodeContents(editorNode);
range.collapse(false); // 折叠到末尾
2025-06-11 08:54:54 +00:00
}
// 尝试使用主选区
2025-06-11 08:54:54 +00:00
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
insertMention(data, range); // 插入@提及元素
2025-06-11 08:54:54 +00:00
} else {
// 如果主选区不可用,尝试创建备用选区
2025-06-11 08:54:54 +00:00
const fallbackRange = document.createRange();
fallbackRange.selectNodeContents(editorNode);
fallbackRange.collapse(false); // 折叠到末尾
const newSelection = window.getSelection();
2025-06-11 08:54:54 +00:00
if (newSelection){
newSelection.removeAllRanges();
newSelection.addRange(fallbackRange);
insertMention(data, fallbackRange); // 使用备用选区插入@提及元素
} else {
2025-06-11 08:54:54 +00:00
console.error("Could not get window selection to insert mention.");
}
}
};
2025-06-05 08:21:39 +00:00
/**
* 处理删除引用元素的键盘事件
*
* @param {KeyboardEvent} e - 键盘事件对象
*
* 该函数监听Backspace和Delete键处理引用元素的删除
* 1. 检查是否按下了Backspace或Delete键
* 2. 获取当前选区
* 3. 准备处理引用元素的删除逻辑
*/
const handleDeleteQuote = function(e) {
// 只处理Backspace和Delete键
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
// 获取当前选区
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
// 获取选区范围和编辑器引用
const range = selection.getRangeAt(0);
const editor = editorRef.value;
// 如果编辑器不存在,直接返回
if (!editor) return;
// 查找编辑器中的引用元素
const quoteElement = editor.querySelector('.editor-quote');
// 如果没有引用元素,移除事件监听器并返回
if (!quoteElement) {
editor.removeEventListener('keydown', handleDeleteQuote);
return;
}
// 获取引用元素在编辑器子节点中的索引位置
const quoteIndex = Array.from(editor.childNodes).indexOf(quoteElement);
// 判断是否在引用元素前按下Backspace键
// 条件按下Backspace键 + 光标已折叠 + 光标容器是编辑器 + 光标位置正好在引用元素前
const isBeforeQuote = e.key === 'Backspace' &&
range.collapsed &&
range.startContainer === editor &&
quoteIndex === range.startOffset;
// 判断是否在引用元素后按下Delete键
// 条件按下Delete键 + 光标已折叠 + 光标容器是编辑器 + 光标位置正好在引用元素后
const isAfterQuote = e.key === 'Delete' &&
range.collapsed &&
range.startContainer === editor &&
quoteIndex === range.startOffset - 1;
// 如果满足删除引用元素的条件
if (isBeforeQuote || isAfterQuote) {
// 阻止默认删除行为
e.preventDefault();
// 移除引用元素
quoteElement.remove();
// 清空引用数据
quoteData.value = null;
// 触发输入事件更新编辑器状态
handleInput({ target: editor });
}
};
/**
* 处理引用事件在编辑器中插入引用元素
*
* @param {Object} data - 引用数据包含标题描述图片等信息
*
* 该函数在编辑器中插入引用元素
* 1. 保存当前引用数据
* 2. 移除已存在的引用元素
* 3. 创建新的引用元素包含标题图片描述和关闭按钮
* 4. 将引用元素插入到编辑器开头
* 5. 在引用元素后添加零宽空格并将光标放置在该位置
* 6. 设置引用元素的点击事件处理程序
*/
2025-06-05 08:21:39 +00:00
const onSubscribeQuote = (data) => {
// 验证编辑器引用和引用数据
2025-06-11 08:54:54 +00:00
if (!editorRef.value || !data) return;
// 保存引用数据到响应式变量
2025-06-11 08:54:54 +00:00
quoteData.value = data;
const editor = editorRef.value;
// 移除编辑器中已存在的引用元素
2025-06-11 08:54:54 +00:00
editor.querySelectorAll('.editor-quote').forEach(quote => quote.remove());
// 获取当前选区,并尝试保存范围(用于后续恢复光标位置)
2025-06-11 08:54:54 +00:00
const selection = window.getSelection();
let savedRange = null;
if (selection && selection.rangeCount > 0) {
const currentRange = selection.getRangeAt(0);
if (editor.contains(currentRange.commonAncestorContainer)) {
savedRange = currentRange.cloneRange();
}
}
// 创建引用元素
2025-06-11 08:54:54 +00:00
const quoteElement = document.createElement('div');
quoteElement.className = 'editor-quote';
quoteElement.contentEditable = 'false'; // 设置为不可编辑
2025-06-11 08:54:54 +00:00
// 创建引用内容包装器
2025-06-11 08:54:54 +00:00
const wrapper = document.createElement('div');
wrapper.className = 'quote-content-wrapper';
// 创建并添加标题元素
2025-06-11 08:54:54 +00:00
const titleDiv = document.createElement('div');
titleDiv.className = 'quote-title';
titleDiv.textContent = data.title || ' '; // 使用提供的标题或空格
2025-06-11 08:54:54 +00:00
wrapper.appendChild(titleDiv);
// 如果有图片,创建并添加图片元素
2025-06-11 08:54:54 +00:00
if (data.image) {
const imageDiv = document.createElement('div');
imageDiv.className = 'quote-image';
const img = document.createElement('img');
img.src = data.image;
img.alt = '引用图片';
imageDiv.appendChild(img);
wrapper.appendChild(imageDiv);
}
// 如果有描述,创建并添加描述元素
2025-06-11 08:54:54 +00:00
if (data.describe) {
const contentDiv = document.createElement('div');
contentDiv.className = 'quote-content';
contentDiv.textContent = data.describe;
wrapper.appendChild(contentDiv);
}
// 将内容包装器添加到引用元素
2025-06-11 08:54:54 +00:00
quoteElement.appendChild(wrapper);
// 创建并添加关闭按钮
2025-06-11 08:54:54 +00:00
const closeButton = document.createElement('div');
closeButton.className = 'quote-close';
closeButton.textContent = '×';
quoteElement.appendChild(closeButton);
// 将引用元素插入到编辑器开头
2025-06-05 08:21:39 +00:00
if (editor.firstChild) {
2025-06-11 08:54:54 +00:00
editor.insertBefore(quoteElement, editor.firstChild);
2025-06-05 08:21:39 +00:00
} else {
2025-06-11 08:54:54 +00:00
editor.appendChild(quoteElement);
2025-06-05 08:21:39 +00:00
}
2025-06-11 08:54:54 +00:00
// 在引用元素后添加零宽空格,并将光标放置在该位置
2025-06-11 08:54:54 +00:00
let nodeToPlaceCursorAfter = quoteElement;
const zeroWidthSpace = document.createTextNode('\u200B'); // 零宽空格,用于放置光标
2025-06-11 08:54:54 +00:00
if (editor.lastChild === quoteElement || !quoteElement.nextSibling) {
// 如果引用元素是最后一个子元素,在其后添加零宽空格
2025-06-11 08:54:54 +00:00
editor.appendChild(zeroWidthSpace);
nodeToPlaceCursorAfter = zeroWidthSpace;
2025-06-05 08:21:39 +00:00
} else {
// 否则,在引用元素和下一个元素之间插入零宽空格
2025-06-11 08:54:54 +00:00
editor.insertBefore(zeroWidthSpace, quoteElement.nextSibling);
nodeToPlaceCursorAfter = zeroWidthSpace;
}
// 处理引用元素的点击事件
2025-06-11 08:54:54 +00:00
const handleQuoteClick = (e) => {
e.stopPropagation(); // 阻止事件冒泡
// 如果点击的是关闭按钮,移除引用元素
2025-06-11 08:54:54 +00:00
if (e.target === closeButton || closeButton.contains(e.target)) {
// 移除引用元素
2025-06-11 08:54:54 +00:00
quoteElement.remove();
// 如果零宽空格仍在编辑器中,也将其移除
2025-06-11 08:54:54 +00:00
if (nodeToPlaceCursorAfter.parentNode === editor && nodeToPlaceCursorAfter.nodeValue === '\u200B') {
nodeToPlaceCursorAfter.remove();
}
// 清空引用数据
2025-06-11 08:54:54 +00:00
quoteData.value = null;
// 移除键盘事件监听器
editor.removeEventListener('keydown', handleDeleteQuote);
// 触发输入事件更新编辑器状态
handleInput();
// 让编辑器重新获得焦点
2025-06-11 08:54:54 +00:00
editor.focus();
2025-06-05 08:21:39 +00:00
} else {
// 如果点击的不是关闭按钮,将光标放在引用元素后面
2025-06-11 08:54:54 +00:00
const newRange = document.createRange();
// 根据节点位置决定光标放置位置
2025-06-11 08:54:54 +00:00
newRange.setStartAfter(nodeToPlaceCursorAfter.parentNode === editor ? nodeToPlaceCursorAfter : quoteElement);
newRange.collapse(true);
if (selection) {
selection.removeAllRanges();
selection.addRange(newRange);
}
editor.focus();
2025-06-05 08:21:39 +00:00
}
2025-06-11 08:54:54 +00:00
};
// 为引用元素添加点击事件监听器
2025-06-11 08:54:54 +00:00
quoteElement.addEventListener('click', handleQuoteClick);
// 为编辑器添加键盘事件监听器,处理引用元素的删除
editor.addEventListener('keydown', handleDeleteQuote);
2025-06-11 08:54:54 +00:00
// 使用setTimeout确保DOM操作完成后再设置光标位置
setTimeout(() => {
// 让编辑器获得焦点
2025-06-05 08:21:39 +00:00
editor.focus();
2025-06-11 08:54:54 +00:00
const newSelection = window.getSelection();
if (!newSelection) return;
// 尝试恢复光标位置
2025-06-11 08:54:54 +00:00
let cursorPlaced = false;
// 如果有保存的选区范围,尝试恢复
if (savedRange) {
2025-06-11 08:54:54 +00:00
try {
// 检查保存的范围是否仍然有效
if (editor.contains(savedRange.commonAncestorContainer) && savedRange.startContainer) {
2025-06-11 08:54:54 +00:00
newSelection.removeAllRanges();
newSelection.addRange(savedRange);
cursorPlaced = true;
}
} catch (err) {
// 忽略恢复选区时可能出现的错误
}
2025-06-05 08:21:39 +00:00
}
2025-06-11 08:54:54 +00:00
// 如果未能恢复光标位置,设置新的光标位置
2025-06-11 08:54:54 +00:00
if (!cursorPlaced) {
const newRange = document.createRange();
// 根据节点位置决定光标放置位置
if (nodeToPlaceCursorAfter && nodeToPlaceCursorAfter.parentNode === editor) {
// 如果零宽空格在编辑器中,将光标放在其后
2025-06-11 08:54:54 +00:00
newRange.setStartAfter(nodeToPlaceCursorAfter);
} else if (quoteElement.parentNode === editor && quoteElement.nextSibling) {
// 如果引用元素有下一个兄弟节点,将光标放在下一个节点前
newRange.setStartAfter(quoteElement.nextSibling);
2025-06-11 08:54:54 +00:00
} else if (quoteElement.parentNode === editor) {
// 如果引用元素是编辑器的子节点,将光标放在其后
newRange.setStartAfter(quoteElement);
2025-06-11 08:54:54 +00:00
} else {
// 如果以上条件都不满足,将光标放在编辑器末尾
newRange.selectNodeContents(editor);
2025-06-11 08:54:54 +00:00
newRange.collapse(false);
}
newRange.collapse(true);
newSelection.removeAllRanges();
newSelection.addRange(newRange);
}
// 滚动到编辑器底部并触发输入事件
editor.scrollTop = editor.scrollHeight;
handleInput();
}, 0); // 0毫秒延迟确保在当前事件循环结束后执行
};
2025-06-05 08:21:39 +00:00
/**
* 处理编辑消息事件
*
* @param {Object} data - 要编辑的消息数据
*
* 该函数在收到编辑消息事件时
* 1. 保存正在编辑的消息数据
* 2. 清空编辑器
* 3. 如果消息有内容将其加载到编辑器中
*/
2025-06-05 08:21:39 +00:00
const onSubscribeEdit = (data) => {
// 保存正在编辑的消息数据
2025-06-05 08:21:39 +00:00
editingMessage.value = data
// 清空编辑器当前内容
2025-06-05 08:21:39 +00:00
clearEditor()
// 如果消息有内容,加载到编辑器中
2025-06-05 08:21:39 +00:00
if (data.content) {
editorRef.value.innerHTML = data.content
editorContent.value = data.content
editorHtml.value = data.content
}
}
/**
* 处理清空编辑器事件
*
* 该函数在收到清空编辑器事件时调用clearEditor函数
*/
2025-06-05 08:21:39 +00:00
const onSubscribeClear = () => {
clearEditor()
}
/**
* 保存编辑器草稿
*
* 该函数负责保存编辑器内容到Pinia Store中
* 1. 创建临时DOM元素复制编辑器内容
* 2. 移除引用元素
* 3. 提取纯文本和HTML内容
* 4. 检查内容是否为空
* 5. 根据内容是否为空更新或删除Pinia Store中的草稿数据
*/
2025-06-05 08:21:39 +00:00
const saveDraft = () => {
// 如果没有索引名称或编辑器引用,直接返回
if (!indexName.value || !editorRef.value) return
2025-06-05 08:21:39 +00:00
// 创建文档片段和临时div元素
const fragment = document.createDocumentFragment()
const tempDiv = document.createElement('div')
// 复制编辑器内容到临时div
tempDiv.innerHTML = editorRef.value.innerHTML
fragment.appendChild(tempDiv)
2025-06-05 08:21:39 +00:00
// 移除引用元素
const quoteElements = tempDiv.querySelectorAll('.editor-quote')
quoteElements.forEach(quote => quote.remove())
// 提取纯文本和HTML内容
const contentToSave = tempDiv.textContent || ''
const htmlToSave = tempDiv.innerHTML || ''
// 解析编辑器内容获取结构化数据
const currentEditor= parseEditorContent().items
// 检查内容是否为空(文本、图片或文件)
const hasContent = contentToSave.trim().length > 0 ||
htmlToSave.includes('<img') ||
htmlToSave.includes('editor-file')
2025-06-05 08:21:39 +00:00
// 根据内容是否为空,更新或删除草稿数据
if (currentEditor.length>0) {
// 有内容,保存草稿
2025-06-05 08:21:39 +00:00
editorDraftStore.items[indexName.value] = JSON.stringify({
// 将结构化内容转换为纯文本(图片转为[图片]标记)
content: currentEditor.reduce((result, x) => {
if (x.type === 3) return result + '[图片]' // 图片类型转为[图片]标记
if (x.type === 1) return result + x.content // 文本类型直接添加内容
return result // 其他类型不处理
}, ''),
html: htmlToSave // 保存HTML内容
2025-06-05 08:21:39 +00:00
})
} else {
// 没有内容,删除草稿
2025-06-05 08:21:39 +00:00
delete editorDraftStore.items[indexName.value]
}
}
/**
* 加载编辑器草稿
*
* 该函数负责从Pinia Store中加载草稿数据到编辑器
* 1. 清空编辑器内容和相关变量
* 2. 尝试从Store中加载并解析草稿数据
* 3. 如果解析成功更新编辑器内容和相关变量
* 4. 如果加载草稿时存在引用数据调用onSubscribeQuote函数处理
*/
2025-06-05 08:21:39 +00:00
const loadDraft = () => {
// 如果没有索引名称,直接返回
2025-06-05 08:21:39 +00:00
if (!indexName.value) return
// 使用nextTick确保DOM更新后再加载草稿
nextTick(() => {
// 保存当前引用数据
2025-06-05 08:21:39 +00:00
const currentQuoteData = quoteData.value
// 清空引用数据
2025-06-05 08:21:39 +00:00
quoteData.value = null
// 如果编辑器引用不存在,直接返回
if (!editorRef.value) return
// 清空编辑器内容和相关变量
editorRef.value.innerHTML = ''
editorContent.value = ''
editorHtml.value = ''
// 从Store中获取草稿数据
2025-06-05 08:21:39 +00:00
const draft = editorDraftStore.items[indexName.value]
// 如果存在草稿数据,尝试加载
2025-06-05 08:21:39 +00:00
if (draft) {
try {
// 解析JSON格式的草稿数据
2025-06-05 08:21:39 +00:00
const draftData = JSON.parse(draft)
// 更新编辑器内容和相关变量
editorRef.value.innerHTML = draftData.html || ''
editorContent.value = draftData.content || ''
editorHtml.value = draftData.html || ''
2025-06-05 08:21:39 +00:00
} catch (error) {
// 解析失败时记录警告并使用空内容
console.warn('加载草稿失败,使用空内容', error)
2025-06-05 08:21:39 +00:00
}
}
// 如果存在引用数据,重新添加到编辑器中
if (currentQuoteData) {
onSubscribeQuote(currentQuoteData)
}
})
2025-06-05 08:21:39 +00:00
}
/**
* 监听索引名称变化自动加载草稿
* 当索引名称变化时调用loadDraft函数加载对应的草稿
* immediate: true 表示组件初始化时立即执行一次
*/
2025-06-05 08:21:39 +00:00
watch(indexName, loadDraft, { immediate: true })
/**
* 处理文档点击事件
* 当点击编辑器外部区域时隐藏@提及列表
*
* @param {Event} event - 点击事件对象
*/
const handleDocumentClick = (event) => {
// 检查点击是否在编辑器外部
if (!editorRef.value?.contains(event.target)) {
// 隐藏@提及列表
hideMentionList()
}
}
/**
* 组件生命周期钩子 - 组件挂载完成
* 初始化编辑器设置事件监听和订阅
*/
2025-06-05 08:21:39 +00:00
onMounted(() => {
// 定义需要订阅的事件和对应的处理函数
const subscriptions = [
[EditorConst.Mention, onSubscribeMention], // @提及事件
[EditorConst.Quote, onSubscribeQuote], // 引用事件
[EditorConst.Edit, onSubscribeEdit], // 编辑事件
[EditorConst.Clear, onSubscribeClear] // 清空事件
]
2025-06-05 08:21:39 +00:00
// 订阅所有事件
subscriptions.forEach(([event, handler]) => {
bus.subscribe(event, handler)
2025-06-05 08:21:39 +00:00
})
// 为编辑器添加点击事件监听器
editorRef.value?.addEventListener('click', handleEditorClick)
// 为文档添加点击事件监听器,用于处理编辑器外部点击
document.addEventListener('click', handleDocumentClick)
2025-06-05 08:21:39 +00:00
// 加载草稿
2025-06-05 08:21:39 +00:00
loadDraft()
})
/**
* 组件生命周期钩子 - 组件卸载前
* 清理所有事件订阅和监听器防止内存泄漏
2025-06-05 08:21:39 +00:00
*/
onBeforeUnmount(() => {
const subscriptions = [
[EditorConst.Mention, onSubscribeMention],
[EditorConst.Quote, onSubscribeQuote],
[EditorConst.Edit, onSubscribeEdit],
[EditorConst.Clear, onSubscribeClear]
]
subscriptions.forEach(([event, handler]) => {
bus.unsubscribe(event, handler)
})
editorRef.value?.removeEventListener('click', handleEditorClick)
2025-06-05 08:21:39 +00:00
document.removeEventListener('click', handleDocumentClick)
2025-06-05 08:21:39 +00:00
const editor = editorRef.value
if (editor && handleDeleteQuote) {
editor.removeEventListener('keydown', handleDeleteQuote)
}
})
/**
* 表情选择事件处理函数
*
* @param {Object} emoji - 选中的表情对象
*
* 当用户从表情选择器中选择表情时触发
* 调用onEmoticonEvent插入表情到编辑器
* 然后关闭表情选择器面板
*/
const onEmoticonSelect = (emoji) => {
2025-06-05 08:21:39 +00:00
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
}
/**
* 处理编辑器点击事件
* 用于处理引用元素的关闭按钮点击和隐藏@提及列表
*
* @param {Event} event - 点击事件对象
*/
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();
}
}
// 检查点击的是否是@提及列表
const isMentionListClick = event.target.closest('.mention-list');
// 如果@提及列表显示中,且点击的不是@提及列表,则隐藏@提及列表
if (showMention.value && !isMentionListClick) {
hideMentionList();
}
};
2025-06-05 08:21:39 +00:00
</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">
2025-06-05 08:21:39 +00:00
<!--
表情选择器弹出框
使用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" @click="sendMessage">
<template #icon>
<n-icon>
<IosSend />
</n-icon>
</template>
发送
</n-button>
2025-06-05 08:21:39 +00:00
</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 && dialogueStore.talk.talk_type === 2"
class="mention-list py-5px"
2025-06-05 08:21:39 +00:00
:style="{ top: mentionPosition.top + 'px', left: mentionPosition.left + 'px' }"
>
<ul class="max-h-140px w-163px overflow-auto hide-scrollbar">
<li
2025-06-05 08:21:39 +00:00
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="handleMentionSelectByMouse(member)"
@mouseover="selectedMentionIndex = index"
2025-06-05 08:21:39 +00:00
>
<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>
2025-06-05 08:21:39 +00:00
</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" />
2025-06-05 08:21:39 +00:00
<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;
2025-06-05 08:21:39 +00:00
flex: auto;
display: flex;
align-items: center;
2025-06-05 08:21:39 +00:00
/**
* 单个工具按钮样式
* 使用flex布局居中对齐内容
* 相对定位用于放置提示文本
*/
.item {
display: flex;
align-items: center;
justify-content: center;
width: 35px;
margin: 0 2px;
position: relative;
user-select: none;
2025-06-05 08:21:39 +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;
white-space: pre;
2025-06-05 08:21:39 +00:00
user-select: none;
z-index: 999999999999;
2025-06-05 08:21:39 +00:00
}
/**
* 悬停效果
* 当鼠标悬停在按钮上时显示提示文本
*/
&: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;
2025-06-05 08:21:39 +00:00
text-decoration: none;
position: relative;
padding-right: 60px;
2025-06-05 08:21:39 +00:00
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
2025-06-05 08:21:39 +00:00
&::after {
content: attr(data-size);
position: absolute;
right: 10px;
color: #757575;
font-size: 12px;
}
&:hover {
background-color: #e3f2fd;
}
}
2025-06-05 08:21:39 +00:00
:deep(.editor-emoji) {
display: inline-block;
width: 24px;
height: 24px;
vertical-align: middle;
2025-06-05 08:21:39 +00:00
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;
2025-06-05 08:21:39 +00:00
/**
* 引用卡片悬停效果
* 改变背景色提供视觉反馈
*/
&: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;
2025-06-05 08:21:39 +00:00
overflow: hidden;
text-overflow: ellipsis;
2025-06-05 08:21:39 +00:00
display: -webkit-box;
-webkit-line-clamp: 2;
2025-06-05 08:21:39 +00:00
-webkit-box-orient: vertical;
}
/**
* 引用图片样式
* 限制图片大小添加圆角
*/
.quote-image img {
max-width: 100px;
max-height: 60px;
border-radius: 3px;
pointer-events: none;
2025-06-05 08:21:39 +00:00
}
/**
* 引用关闭按钮样式
* 圆形按钮悬停时改变背景色和文字颜色
*/
.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;
&:empty:before {
content: attr(placeholder);
color: #999;
pointer-events: none;
}
2025-06-05 08:21:39 +00:00
/**
* 自定义滚动条样式 - 轨道
* 使用::-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;
2025-06-05 08:21:39 +00:00
font-family: PingFang SC, Microsoft YaHei, 'Alibaba PuHuiTi 2.0 45' !important;
}
/**
* 编辑器聚焦样式
* 移除默认的聚焦轮廓
*/
.custom-editor:focus {
outline: none;
}
2025-06-11 08:54:54 +00:00
2025-06-05 08:21:39 +00:00
/**
* @提及悬停效果
* 当鼠标悬停在@提及上时改变背景色
*/
.mention:hover {
background-color: #bae7ff;
}
/**
* 图片样式
* 限制编辑器中插入的图片大小
* 添加圆角和鼠标指针样式
*/
2025-06-05 08:21:39 +00:00
/**
* 表情样式
* 设置表情图片的大小和对齐方式
*/
.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;
}
2025-06-05 08:21:39 +00:00
.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;
}
}
/**
* 图片上传加载样式
*/
:deep(.editor-image-wrapper.image-upload-loading::before) {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 24px;
margin-top: -12px;
margin-left: -12px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-top-color: #333;
border-radius: 50%;
animation: spin 0.6s linear infinite;
z-index: 1;
}
:deep(.editor-image-wrapper.image-upload-loading img) {
opacity: 0.5;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/**
* 隐藏滚动条样式
* 保留滚动功能但隐藏滚动条的视觉显示
*/
.hide-scrollbar {
&::-webkit-scrollbar {
width: 0;
display: none;
}
scrollbar-width: none;
-ms-overflow-style: none;
}
2025-06-05 08:21:39 +00:00
</style>