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

1109 lines
27 KiB
Vue
Raw Normal View History

<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 = Extension.create({
name: 'quote',
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
// 创建引用卡片的HTML结构
const quoteCardContent = document.createElement('span')
quoteCardContent.classList.add('quote-card-content')
const close = document.createElement('span')
close.classList.add('quote-card-remove')
close.textContent = '×'
close.addEventListener('click', (e) => {
e.stopPropagation()
// 移除引用
if (editor.value) {
editor.value.commands.deleteNode('quote')
}
})
const quoteCardTitle = document.createElement('span')
quoteCardTitle.classList.add('quote-card-title')
quoteCardTitle.textContent = title
quoteCardTitle.appendChild(close)
quoteCardContent.appendChild(quoteCardTitle)
if (!image || image.length === 0) {
const quoteCardMeta = document.createElement('span')
quoteCardMeta.classList.add('quote-card-meta')
quoteCardMeta.textContent = describe
quoteCardContent.appendChild(quoteCardMeta)
} else {
const iconImg = document.createElement('img')
iconImg.setAttribute('src', image)
iconImg.setAttribute('style', 'width:30px;height:30px;margin-right:10px;')
quoteCardContent.appendChild(iconImg)
}
const node = document.createElement('div')
node.classList.add('quote-card')
node.setAttribute('data-id', id)
node.setAttribute('data-title', title)
node.setAttribute('data-describe', describe)
node.setAttribute('data-image', image || '')
node.setAttribute('contenteditable', 'false')
node.appendChild(quoteCardContent)
return ['div', { class: 'quote-card-wrapper' }, node]
},
addKeyboardShortcuts() {
return {
'Backspace': () => {
const { selection } = this.editor.state
const { empty, anchor } = selection
if (!empty) {
return false
}
const isAtStart = anchor === 0
if (!isAtStart) {
return false
}
// 检查是否有引用节点
const quoteNode = this.editor.state.doc.firstChild
if (quoteNode && quoteNode.type.name === 'quote') {
this.editor.commands.deleteNode('quote')
return true
}
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 { items: [], mentions: [], mentionUids: [], quoteId: '', msgType: 1 }
const json = editor.value.getJSON()
const resp = {
items: [],
mentions: [],
mentionUids: [],
quoteId: '',
msgType: 1
}
// 处理引用
const quoteNode = json.content?.find((node) => node.type === 'quote')
if (quoteNode) {
resp.quoteId = quoteNode.attrs.id
}
// 处理内容
let textContent = ''
let hasImage = false
const processNode = (node) => {
if (node.type === 'text') {
textContent += node.text
} else if (node.type === 'mention') {
textContent += ` @${node.attrs.label} `
resp.mentions.push({
name: `@${node.attrs.label}`,
atid: parseInt(node.attrs.id)
})
} else if (node.type === 'emoji') {
textContent += node.attrs.alt
} else if (node.type === 'image') {
hasImage = true
resp.items.push({
type: 3,
content: node.attrs.src
})
} else if (node.content) {
node.content.forEach(processNode)
}
}
if (json.content) {
json.content.forEach(processNode)
}
// 如果有文本内容添加到items
if (textContent.trim()) {
resp.items.unshift({
type: 1,
content: textContent.trim()
})
}
// 设置消息类型
if (resp.items.length > 1) {
resp.msgType = 12 // 混合消息
} else if (resp.items.length === 1) {
resp.msgType = resp.items[0].type
}
resp.mentionUids = resp.mentions.map((item) => item.atid)
return resp
}
// 将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) return
if (isEditorEmpty()) return
const data = tiptapToMessage()
if (data.items.length === 0 || (data.items[0].type === 1 && !data.items[0].content.trim())) {
return // 没有内容不发送
}
switch (data.msgType) {
case 1: // 文字消息
if (data.items[0].content.length > 1024) {
return window['$message'].info('发送内容超长,请分条发送')
}
// 发送文本消息
emit(
'editor-event',
emitCall('text_event', data, (ok) => {
ok && editor.value?.commands.clearContent(true) // 成功发送后清空编辑器
})
)
break
case 3: // 图片消息
// 发送图片消息
emit(
'editor-event',
emitCall(
'image_event',
{ ...getImageInfo(data.items[0].content), url: data.items[0].content, size: 10000 },
(ok) => {
ok && editor.value?.commands.clearContent(true) // 成功发送后清空编辑器
}
)
)
break
case 12: // 图文混合消息
// 发送混合消息
emit(
'editor-event',
emitCall('mixed_event', data, (ok) => {
ok && editor.value?.commands.clearContent(true) // 成功发送后清空编辑器
})
)
break
}
}
/**
* 编辑器内容改变时的处理
* 保存草稿并触发输入事件
*/
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().insertContent({
type: 'quote',
attrs: data,
}).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() // 触发文件上传
}
},
// 以下功能已被注释掉,但保留代码
// {
// title: '代码',
// icon: markRaw(SourceCode),
// show: true,
// click: () => {
// isShowEditorCode.value = true
// }
// },
// {
// title: '语音消息',
// icon: markRaw(IconVoice),
// show: true,
// click: () => {
// isShowEditorRecorder.value = true
// }
// },
// {
// title: '地理位置',
// icon: markRaw(Local),
// show: true,
// click: () => {}
// },
// {
// title: '群投票',
// icon: markRaw(Ranking),
// show: computed(() => props.vote),
// click: () => {
// isShowEditorVote.value = true
// }
// },
// {
// title: '历史记录',
// icon: markRaw(History),
// show: true,
// click: () => {
// emit('editor-event', emitCall('history_event'))
// }
// }
])
// 监听聊天索引变化,切换聊天时加载对应草稿
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>