chat-pc/src/components/editor/TiptapEditor.vue
Phoenix 8be8afc675 refactor(TiptapEditor): 重构消息转换逻辑以支持多消息分段处理
重构 tiptapToMessage 函数,将单条消息处理改为支持多条消息分段处理
优化消息内容处理流程,添加文本缓冲区和图片单独处理逻辑
简化消息发送逻辑,移除 msgType 判断改为直接处理不同类型消息
清理已注释的导航功能代码
2025-07-02 13:34:23 +08:00

1063 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<script setup>
// 引入Tiptap编辑器相关依赖
import { Editor, EditorContent, useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import Mention from '@tiptap/extension-mention'
import Link from '@tiptap/extension-link'
import { Extension, Node } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
// 引入Vue核心功能
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted, shallowRef } from 'vue'
// 引入Naive UI的弹出框组件
import { NPopover } from 'naive-ui'
// 引入图标组件
import {
Voice as IconVoice, // 语音图标
SourceCode, // 代码图标
Local, // 地理位置图标
SmilingFace, // 表情图标
Pic, // 图片图标
FolderUpload, // 文件上传图标
Ranking, // 排名图标(用于投票)
History // 历史记录图标
} from '@icon-park/vue-next'
// 引入状态管理
import { useDialogueStore, useEditorDraftStore } from '@/store'
// 引入获取图片信息的工具函数
import { getImageInfo } from '@/utils/functions'
// 引入编辑器常量定义
import { EditorConst } from '@/constant/event-bus'
// 引入事件调用工具
import { emitCall } from '@/utils/common'
// 引入默认头像常量
import { defAvatar } from '@/constant/default'
// 引入编辑器各子组件
import MeEditorVote from './MeEditorVote.vue' // 投票组件
import MeEditorEmoticon from './MeEditorEmoticon.vue' // 表情组件
import MeEditorCode from './MeEditorCode.vue' // 代码编辑组件
import MeEditorRecorder from './MeEditorRecorder.vue' // 录音组件
// 引入上传API
import { uploadImg } from '@/api/upload'
// 引入事件总线钩子
import { useEventBus } from '@/hooks'
// 定义组件的事件
const emit = defineEmits(['editor-event'])
// 获取对话状态管理
const dialogueStore = useDialogueStore()
// 获取编辑器草稿状态管理
const editorDraftStore = useEditorDraftStore()
// 定义组件props
const props = defineProps({
vote: {
type: Boolean,
default: false // 是否显示投票功能
},
members: {
default: () => [] // 聊天成员列表,用于@功能
}
})
// 计算当前对话索引名称(标识当前聊天)
const indexName = computed(() => dialogueStore.index_name)
// 控制是否显示编辑器的投票界面
const isShowEditorVote = ref(false)
// 控制是否显示编辑器的代码界面
const isShowEditorCode = ref(false)
// 控制是否显示录音界面
const isShowEditorRecorder = ref(false)
// 图片文件上传DOM引用
const fileImageRef = ref()
// 文件上传DOM引用
const uploadFileRef = ref()
// 表情面板引用
const emoticonRef = ref()
// 表情面板显示状态
const showEmoticon = ref(false)
// 自定义Emoji扩展
const Emoji = Node.create({
name: 'emoji',
group: 'inline',
inline: true,
selectable: true,
atom: true,
addAttributes() {
return {
alt: {
default: null,
},
src: {
default: null,
},
width: {
default: '24px',
},
height: {
default: '24px',
},
class: {
default: 'ed-emoji',
},
}
},
parseHTML() {
return [
{
tag: 'img.ed-emoji',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['img', HTMLAttributes]
},
})
// 自定义Quote扩展
const Quote = Node.create({
name: 'quote',
group: 'block',
atom: true,
draggable: true,
addAttributes() {
return {
id: { default: null },
title: { default: null },
describe: { default: null },
image: { default: '' }
}
},
parseHTML() {
return [{ tag: 'div.quote-card' }]
},
renderHTML({ HTMLAttributes }) {
const { id, title, describe, image } = HTMLAttributes
const titleEl = ['span', { class: 'quote-card-title' }, title || '']
let contentChildren = [titleEl]
if (image && image.length > 0) {
contentChildren.push(['img', { src: image, style: 'width:30px;height:30px;margin-right:10px;' }])
} else if (describe) {
contentChildren.push(['span', { class: 'quote-card-meta' }, describe])
}
const cardContent = ['span', { class: 'quote-card-content' }, ...contentChildren]
return [
'div',
{
class: 'quote-card',
'data-id': id,
'data-title': title,
'data-describe': describe,
'data-image': image || '',
contenteditable: 'false'
},
cardContent
]
},
addKeyboardShortcuts() {
return {
Backspace: () => {
const { selection } = this.editor.state
const { $from, empty } = selection
if (!empty) {
return false
}
if ($from.parent.isTextblock && $from.parentOffset === 0) {
const nodeBefore = $from.nodeBefore
if (nodeBefore && nodeBefore.type.name === this.name) {
return this.editor.commands.deleteNode(this.name)
}
}
return false
}
}
}
})
// 创建自定义键盘处理插件处理Enter键发送消息
const EnterKeyPlugin = new Plugin({
key: new PluginKey('enterKey'),
props: {
handleKeyDown: (view, event) => {
// 如果按下Enter键且没有按下Shift键则发送消息
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
onSendMessage()
return true
}
return false
},
},
})
// 自定义键盘扩展
const CustomKeyboard = Extension.create({
name: 'customKeyboard',
addProseMirrorPlugins() {
return [
EnterKeyPlugin,
]
},
})
// 创建编辑器实例
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
inline: true,
allowBase64: true,
}),
Placeholder.configure({
placeholder: '按Enter发送 / Shift+Enter 换行',
}),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: {
items: ({ query }) => {
if (!props.members.length) {
return []
}
let list = [...props.members]
if ((dialogueStore.groupInfo).is_manager) {
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' })
}
return list.filter(
(item) => item.nickname.toLowerCase().includes(query.toLowerCase())
)
},
render: () => {
let component
let popup
return {
onStart: (props) => {
// 创建提及列表容器
popup = document.createElement('div')
popup.classList.add('ql-mention-list-container', 'me-scrollbar', 'me-scrollbar-thumb')
document.body.appendChild(popup)
// 渲染提及列表
props.items.forEach((item, index) => {
const mentionItem = document.createElement('div')
mentionItem.classList.add('ed-member-item')
mentionItem.innerHTML = `<img src="${item.avatar}" class="avator"/><span class="nickname">${item.nickname}</span>`
mentionItem.addEventListener('click', () => {
props.command({ id: item.id, label: item.nickname })
})
if (index === props.selectedIndex) {
mentionItem.classList.add('selected')
}
popup.appendChild(mentionItem)
})
// 定位提及列表
const coords = props.clientRect()
popup.style.position = 'fixed'
popup.style.top = `${coords.top + window.scrollY}px`
popup.style.left = `${coords.left + window.scrollX}px`
},
onUpdate: (props) => {
// 更新选中项
const items = popup.querySelectorAll('.ed-member-item')
items.forEach((item, index) => {
if (index === props.selectedIndex) {
item.classList.add('selected')
} else {
item.classList.remove('selected')
}
})
},
onKeyDown: (props) => {
// 处理键盘事件
if (props.event.key === 'Escape') {
popup.remove()
return true
}
return false
},
onExit: () => {
// 清理
if (popup) {
popup.remove()
}
},
}
},
},
}),
Link,
Emoji,
Quote,
CustomKeyboard,
],
content: '',
autofocus: true,
editable: true,
injectCSS: false,
onUpdate: () => {
onEditorChange()
},
})
/**
* 上传图片函数
* @param file 文件对象
* @returns Promise成功时返回图片URL
*/
function onUploadImage(file) {
return new Promise((resolve) => {
let image = new Image()
image.src = URL.createObjectURL(file)
image.onload = () => {
const form = new FormData()
form.append('file', file)
form.append("source", "fonchain-chat"); // 图片来源标识
// 添加图片尺寸信息作为URL参数
form.append("urlParam", `width=${image.width}&height=${image.height}`);
// 调用上传API
uploadImg(form).then(({ code, data, message }) => {
if (code == 0) {
resolve(data.ori_url) // 返回原始图片URL
} else {
resolve('')
window['$message'].error(message) // 显示错误信息
}
})
}
})
}
/**
* 投票事件处理
* @param data 投票数据
*/
function onVoteEvent(data) {
const msg = emitCall('vote_event', data, (ok) => {
if (ok) {
isShowEditorVote.value = false // 成功后关闭投票界面
}
})
emit('editor-event', msg)
}
/**
* 表情事件处理
* @param data 表情数据
*/
function onEmoticonEvent(data) {
// 关闭表情面板
showEmoticon.value = false
if (data.type == 1) {
// 插入文本表情
if (!editor.value) return
if (data.img) {
// 插入图片表情
editor.value.chain().focus().insertContent({
type: 'emoji',
attrs: {
alt: data.value,
src: data.img,
width: '24px',
height: '24px',
},
}).run()
} else {
// 插入文本表情
editor.value.chain().focus().insertContent(data.value).run()
}
} else {
// 发送整个表情包
let fn = emitCall('emoticon_event', data.value, () => {})
emit('editor-event', fn)
}
}
/**
* 代码事件处理
* @param data 代码数据
*/
function onCodeEvent(data) {
const msg = emitCall('code_event', data, (ok) => {
isShowEditorCode.value = false // 成功后关闭代码界面
})
emit('editor-event', msg)
}
/**
* 文件上传处理
* @param e 上传事件对象
*/
async function onUploadFile(e) {
let file = e.target.files[0]
e.target.value = null // 清空input允许再次选择相同文件
if (file.type.indexOf('image/') === 0) {
// 处理图片文件 - 立即显示临时消息,然后上传
let fn = emitCall('image_event', file, () => {})
emit('editor-event', fn)
return
}
if (file.type.indexOf('video/') === 0) {
// 处理视频文件
let fn = emitCall('video_event', file, () => {})
emit('editor-event', fn)
} else {
// 处理其他类型文件
let fn = emitCall('file_event', file, () => {})
emit('editor-event', fn)
}
}
/**
* 录音事件处理
* @param file 录音文件
*/
function onRecorderEvent(file) {
emit('editor-event', emitCall('file_event', file))
isShowEditorRecorder.value = false // 关闭录音界面
}
// 将Tiptap内容转换为消息格式
function tiptapToMessage() {
if (!editor.value) return []
const json = editor.value.getJSON()
const messages = []
let quoteId = null
let currentTextBuffer = ''
let currentMentions = []
let currentMentionUids = new Set()
const flushTextBuffer = () => {
const content = currentTextBuffer.trim()
if (content) {
const data = {
items: [{ type: 1, content: content }],
mentions: [...currentMentions],
mentionUids: Array.from(currentMentionUids)
}
if (quoteId) {
data.quoteId = quoteId
quoteId = null
}
messages.push({ type: 'text', data })
}
currentTextBuffer = ''
currentMentions = []
currentMentionUids.clear()
}
const processInlines = nodes => {
nodes.forEach(node => {
if (node.type === 'text') {
currentTextBuffer += node.text
} else if (node.type === 'mention') {
currentTextBuffer += `@${node.attrs.label} `
const uid = parseInt(node.attrs.id)
if (!currentMentionUids.has(uid)) {
currentMentionUids.add(uid)
currentMentions.push({ name: `@${node.attrs.label}`, atid: uid })
}
} else if (node.type === 'emoji') {
currentTextBuffer += node.attrs.alt
} else if (node.type === 'hardBreak') {
currentTextBuffer += '\n'
} else if (node.type === 'image') {
// 处理段落内的图片
flushTextBuffer()
const data = {
...getImageInfo(node.attrs.src),
url: node.attrs.src
}
if (quoteId) {
data.quoteId = quoteId
quoteId = null
}
messages.push({ type: 'image', data })
}
})
}
if (json.content) {
const quoteIndex = json.content.findIndex(node => node.type === 'quote')
if (quoteIndex > -1) {
quoteId = json.content[quoteIndex].attrs.id
json.content.splice(quoteIndex, 1)
}
json.content.forEach(node => {
if (node.type === 'paragraph') {
if (node.content) {
processInlines(node.content)
}
currentTextBuffer += '\n' // Add newline after each paragraph
} else if (node.type === 'image') {
flushTextBuffer()
const data = {
...getImageInfo(node.attrs.src),
url: node.attrs.src
}
if (quoteId) {
data.quoteId = quoteId
quoteId = null
}
messages.push({ type: 'image', data })
}
})
}
flushTextBuffer()
if (messages.length > 0) {
const lastMessage = messages[messages.length - 1]
if (lastMessage.type === 'text') {
lastMessage.data.items[0].content = lastMessage.data.items[0].content.trim()
if (!lastMessage.data.items[0].content) {
messages.pop()
}
}
}
return messages
}
// 将Tiptap内容转换为纯文本
function tiptapToString() {
if (!editor.value) return ''
return editor.value.getText()
}
// 检查编辑器是否为空
function isEditorEmpty() {
if (!editor.value) return true
const json = editor.value.getJSON()
// 检查是否只有一个空段落
return !json.content || (
json.content.length === 1 &&
json.content[0].type === 'paragraph' &&
(!json.content[0].content || json.content[0].content.length === 0)
)
}
/**
* 发送消息处理
* 根据编辑器内容类型发送不同类型的消息
*/
function onSendMessage() {
if (!editor.value || isEditorEmpty()) return
const messages = tiptapToMessage()
if (messages.length === 0) {
return
}
let canClear = true
messages.forEach(msg => {
if (msg.type === 'text') {
if (msg.data.items[0].content.length > 1024) {
window['$message'].info('发送内容超长,请分条发送')
canClear = false
return
}
emit('editor-event', emitCall('text_event', msg.data))
} else if (msg.type === 'image') {
const data = {
height: 0,
width: 0,
size: 10000,
url: msg.data.url,
}
emit('editor-event', emitCall('image_event', data))
}
})
if (canClear) {
editor.value?.commands.clearContent(true)
}
}
/**
* 编辑器内容改变时的处理
* 保存草稿并触发输入事件
*/
function onEditorChange() {
if (!editor.value) return
const text = tiptapToString()
if (!isEditorEmpty()) {
// 保存草稿到store
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
text: text,
content: editor.value.getJSON()
})
} else {
// 编辑器为空时删除对应草稿
delete editorDraftStore.items[indexName.value || '']
}
// 触发输入事件
emit('editor-event', emitCall('input_event', text))
}
/**
* 加载编辑器草稿内容
* 当切换聊天对象时,加载对应的草稿
*/
function loadEditorDraftText() {
if (!editor.value) return
// 从缓存中加载编辑器草稿
let draft = editorDraftStore.items[indexName.value || '']
if (draft) {
const parsed = JSON.parse(draft)
if (parsed.content) {
editor.value.commands.setContent(parsed.content)
} else if (parsed.text) {
editor.value.commands.setContent(parsed.text)
}
} else {
editor.value.commands.clearContent(true) // 没有草稿则清空编辑器
}
// 设置光标位置到末尾
editor.value.commands.focus('end')
}
/**
* 处理@成员事件
* @param data @成员数据
*/
function onSubscribeMention(data) {
if (!editor.value) return
// 插入@项
editor.value.chain().focus().insertContent({
type: 'mention',
attrs: {
id: data?.id,
label: data.value,
},
}).run()
}
/**
* 处理引用事件
* @param data 引用数据
*/
function onSubscribeQuote(data) {
if (!editor.value) return
// 检查是否已有引用内容
const json = editor.value.getJSON()
if (json.content?.some(node => node.type === 'quote')) {
return // 已有引用则不再添加
}
// 在编辑器开头插入引用
editor.value
.chain()
.focus()
.insertContentAt(0, [
{
type: 'quote',
attrs: data
},
{
type: 'paragraph'
}
])
.run()
}
/**
* 处理编辑消息事件
* @param data 消息数据
*/
function onSubscribeEdit(data) {
if (!editor.value) return
// 清空当前编辑器内容
editor.value.commands.clearContent(true)
// 插入要编辑的文本内容
editor.value.commands.insertContent(data.content)
// 设置光标位置到末尾
editor.value.commands.focus('end')
}
// 底部工具栏配置
const navs = reactive([
{
title: '图片',
icon: markRaw(Pic),
show: true,
click: () => {
fileImageRef.value.click() // 触发图片上传
}
},
{
title: '文件',
icon: markRaw(FolderUpload),
show: true,
click: () => {
uploadFileRef.value.click() // 触发文件上传
}
},
])
// 监听聊天索引变化,切换聊天时加载对应草稿
watch(indexName, loadEditorDraftText, { immediate: true })
// 组件挂载时初始化
onMounted(() => {
loadEditorDraftText()
})
// 订阅编辑器相关事件总线事件
useEventBus([
{ name: EditorConst.Mention, event: onSubscribeMention }, // @成员事件
{ name: EditorConst.Quote, event: onSubscribeQuote }, // 引用事件
{ name: EditorConst.Edit, event: onSubscribeEdit } // 编辑消息事件
])
</script>
<template>
<!-- 编辑器容器 -->
<section class="el-container editor">
<section class="el-container is-vertical">
<!-- 工具栏区域 -->
<header class="el-header toolbar bdr-t">
<div class="tools">
<!-- 表情选择器弹出框 -->
<n-popover
placement="top-start"
trigger="click"
raw
:show-arrow="false"
:width="300"
ref="emoticonRef"
v-model:show="showEmoticon"
style="width: 500px; height: 250px; border-radius: 10px; overflow: hidden"
>
<template #trigger>
<div class="item pointer">
<n-icon size="18" class="icon" :component="SmilingFace" />
<p class="tip-title">表情符号</p>
</div>
</template>
<MeEditorEmoticon @on-select="onEmoticonEvent" />
</n-popover>
<!-- 工具栏其他功能按钮 -->
<div
class="item pointer"
v-for="nav in navs"
:key="nav.title"
v-show="nav.show"
@click="nav.click"
>
<n-icon size="18" class="icon" :component="nav.icon" />
<p class="tip-title">{{ nav.title }}</p>
</div>
</div>
</header>
<!-- 编辑器主体区域 -->
<main class="el-main height100">
<editor-content :editor="editor" class="tiptap-editor" />
</main>
</section>
</section>
<!-- 隐藏的文件上传表单 -->
<form enctype="multipart/form-data" style="display: none">
<input type="file" ref="fileImageRef" accept="image/*" @change="onUploadFile" />
<input type="file" ref="uploadFileRef" @change="onUploadFile" />
</form>
<!-- 条件渲染的功能组件 -->
<MeEditorVote v-if="isShowEditorVote" @close="isShowEditorVote = false" @submit="onVoteEvent" />
<MeEditorCode
v-if="isShowEditorCode"
@on-submit="onCodeEvent"
@close="isShowEditorCode = false"
/>
<MeEditorRecorder
v-if="isShowEditorRecorder"
@on-submit="onRecorderEvent"
@close="isShowEditorRecorder = false"
/>
</template>
<style lang="less" scoped>
.editor {
--tip-bg-color: rgb(241 241 241 / 90%); /* 提示背景颜色 */
height: 100%;
.toolbar {
height: 38px;
display: flex;
.tools {
height: 100%;
flex: auto;
display: flex;
.item {
display: flex;
align-items: center;
justify-content: center;
width: 35px;
margin: 0 2px;
position: relative;
user-select: none;
.tip-title {
display: none; /* 默认隐藏提示文字 */
position: absolute;
top: 40px;
left: 0px;
line-height: 26px;
background-color: var(--tip-bg-color);
color: var(--im-text-color);
min-width: 20px;
font-size: 12px;
padding: 0 5px;
border-radius: 2px;
white-space: pre;
user-select: none;
z-index: 999999999999;
}
&:hover {
.tip-title {
display: block; /* 悬停时显示提示文字 */
}
}
}
}
}
}
/* 暗色模式样式调整 */
html[theme-mode='dark'] {
.editor {
--tip-bg-color: #48484d;
}
}
</style>
<style lang="less">
/* 全局编辑器样式 */
.tiptap-editor {
height: 100%;
overflow: auto;
padding: 8px;
outline: none;
/* 滚动条样式 */
&::-webkit-scrollbar {
width: 3px;
height: 3px;
background-color: unset;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: transparent;
}
/* 悬停时显示滚动条 */
&:hover {
&::-webkit-scrollbar-thumb {
background-color: var(--im-scrollbar-thumb);
}
}
p {
margin: 0;
}
/* 占位符样式 */
.is-empty::before {
content: attr(data-placeholder);
float: left;
color: #b8b3b3;
pointer-events: none;
height: 0;
font-family: PingFang SC, Microsoft YaHei, 'Alibaba PuHuiTi 2.0 45' !important;
}
/* 编辑器中图片样式 */
img {
max-width: 100px;
border-radius: 3px;
background-color: #48484d;
margin: 0px 2px;
}
/* 表情符号样式 */
.ed-emoji {
background-color: unset !important;
}
/* 提及样式 */
.mention {
color: #0366d6;
background-color: rgba(3, 102, 214, 0.1);
border-radius: 2px;
padding: 0 2px;
}
/* 引用卡片样式 */
.quote-card-wrapper {
margin-bottom: 8px;
}
.quote-card-content {
display: flex;
background-color: #f6f6f6;
flex-direction: column;
padding: 8px;
margin-bottom: 5px;
cursor: pointer;
user-select: none;
.quote-card-title {
height: 22px;
line-height: 22px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
justify-content: space-between;
.quote-card-remove {
margin-right: 15px;
font-size: 18px;
}
}
.quote-card-meta {
margin-top: 4px;
font-size: 12px;
line-height: 20px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
/* 提及列表样式 */
.ql-mention-list-container {
width: 270px;
max-height: 200px;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border-radius: 4px;
overflow-y: auto;
z-index: 10000;
.ed-member-item {
display: flex;
align-items: center;
padding: 5px 10px;
cursor: pointer;
&:hover, &.selected {
background-color: #f5f7fa;
}
.avator {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 8px;
}
.nickname {
font-size: 14px;
}
}
}
/* 暗色模式下的样式调整 */
html[theme-mode='dark'] {
.tiptap-editor {
.is-empty::before {
color: #57575a;
}
.quote-card-content {
background-color: var(--im-message-bg-color);
}
}
.ql-mention-list-container {
background-color: #1e1e1e;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.3);
.ed-member-item {
&:hover, &.selected {
background-color: #2c2c2c;
}
.nickname {
color: #e0e0e0;
}
}
}
}
</style>