chat-pc/src/components/editor/TiptapEditor.vue
Phoenix a438174af4 fix(editor): 修复建议组件空项处理和粘贴功能优化
修复建议组件在空项时的处理逻辑,避免潜在错误。优化编辑器粘贴功能:
1. 处理空剪贴板数据时更安全
2. 添加纯文本粘贴支持
3. 简化图片节点更新逻辑
2025-07-07 13:41:26 +08:00

1066 lines
23 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>
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 { computePosition, flip, shift } from '@floating-ui/dom'
import Link from '@tiptap/extension-link'
import { Extension, Node } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted, shallowRef } from 'vue'
import { NPopover, NIcon } from 'naive-ui'
import {
Voice as IconVoice,
SourceCode,
Local,
SmilingFace,
Pic,
FolderUpload,
Ranking,
History,
Close
} 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 suggestion from './suggestion.js'
import MeEditorVote from './MeEditorVote.vue'
import MeEditorEmoticon from './MeEditorEmoticon.vue'
import MeEditorCode from './MeEditorCode.vue'
import MeEditorRecorder from './MeEditorRecorder.vue'
import { uploadImg } from '@/api/upload'
import { useEventBus } from '@/hooks'
const emit = defineEmits(['editor-event'])
const dialogueStore = useDialogueStore()
const editorDraftStore = useEditorDraftStore()
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)
const uploadingImages = ref(new Map())
const fileImageRef = ref()
const uploadFileRef = ref()
const emoticonRef = ref()
const showEmoticon = ref(false)
const quoteData = ref(null)
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]
},
})
const EnterKeyPlugin = new Plugin({
key: new PluginKey('enterKey'),
props: {
handleKeyDown: (view, event) => {
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.extend({
addNodeView() {
return ({ node, getPos, editor }) => {
const container = document.createElement('span')
container.style.position = 'relative'
container.style.display = 'inline-block'
const img = document.createElement('img')
img.setAttribute('src', node.attrs.src)
img.style.maxWidth = '100px'
img.style.borderRadius = '3px'
img.style.backgroundColor = '#48484d'
img.style.margin = '0px 2px'
container.appendChild(img)
if (uploadingImages.value.has(node.attrs.src)) {
container.classList.add('image-upload-loading')
}
const stopWatch = watch(uploadingImages, () => {
if (uploadingImages.value.has(node.attrs.src)) {
container.classList.add('image-upload-loading')
} else {
container.classList.remove('image-upload-loading')
}
}, { deep: true })
return {
dom: container,
destroy() {
stopWatch()
}
}
}
}
}).configure({
inline: true,
allowBase64: true,
}),
Placeholder.configure({
placeholder: '按Enter发送 / Shift+Enter 换行',
}),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: {
...suggestion,
char: '@',
allowSpaces: false,
allowedPrefixes: null,
items: ({ query }) => {
return suggestion.items({
query,
props: {
members: props.members,
isGroupManager: (dialogueStore.groupInfo).is_manager
}
})
},
},
}),
Link,
Emoji,
CustomKeyboard,
],
content: '',
autofocus: true,
editable: true,
injectCSS: false,
onUpdate: () => {
onEditorChange()
},
editorProps: {
handlePaste: (view, event) => {
const clipboardData = event.clipboardData || event.originalEvent.clipboardData
const items = clipboardData.items
for (const item of items) {
if (item.type.indexOf('image') === 0) {
event.preventDefault()
const file = item.getAsFile()
if (!file) continue
const tempUrl = URL.createObjectURL(file)
const { state, dispatch } = view
const { tr } = state
const node = state.schema.nodes.image.create({ src: tempUrl })
dispatch(tr.replaceSelectionWith(node))
const form = new FormData()
form.append('file', file)
form.append('source', 'fonchain-chat')
uploadingImages.value.set(tempUrl, true)
uploadImg(form)
.then(({ code, data, message }) => {
if (code === 0 && data.ori_url) {
const pos = findImagePos(tempUrl)
if (pos !== -1) {
const { tr } = view.state
view.dispatch(tr.setNodeMarkup(pos, null, { src: data.ori_url }))
}
} else {
window['$message'].error(message || '图片上传失败')
removeImage(tempUrl)
}
})
.catch(() => {
window['$message'].error('图片上传失败')
removeImage(tempUrl)
})
.finally(() => {
uploadingImages.value.delete(tempUrl)
URL.revokeObjectURL(tempUrl)
})
return true // Handled
}
}
// If no image was handled, check for text and paste as plain text.
const text = clipboardData.getData('text/plain')
if (text) {
event.preventDefault()
const { state, dispatch } = view
dispatch(state.tr.insertText(text))
return true // Handled
}
return false // Fallback for other cases
}
}
})
function findImagePos(url) {
if (!editor.value) return -1
let pos = -1
editor.value.state.doc.descendants((node, p) => {
if (node.type.name === 'image' && node.attrs.src === url) {
pos = p
return false
}
return true
})
return pos
}
function removeImage(url) {
if (!editor.value) return
const pos = findImagePos(url)
if (pos !== -1) {
const { tr } = editor.value.state
editor.value.view.dispatch(tr.delete(pos, pos + 1))
}
}
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");
form.append("urlParam", `width=${image.width}&height=${image.height}`);
uploadImg(form).then(({ code, data, message }) => {
if (code == 0) {
resolve(data.ori_url)
} else {
resolve('')
window['$message'].error(message)
}
})
}
})
}
function onVoteEvent(data) {
const msg = emitCall('vote_event', data, (ok) => {
if (ok) {
isShowEditorVote.value = false
}
})
emit('editor-event', msg)
}
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)
}
}
function onCodeEvent(data) {
const msg = emitCall('code_event', data, (ok) => {
isShowEditorCode.value = false
})
emit('editor-event', msg)
}
async function onUploadFile(e) {
let file = e.target.files[0]
e.target.value = null
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)
}
}
function onRecorderEvent(file) {
emit('editor-event', emitCall('file_event', file))
isShowEditorRecorder.value = false
}
function tiptapToMessage() {
if (!editor.value) return []
const json = editor.value.getJSON()
const messages = []
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)
}
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
}
messages.push({ type: 'image', data })
}
})
}
if (json.content) {
json.content.forEach(node => {
if (node.type === 'paragraph') {
if (node.content) {
processInlines(node.content)
}
currentTextBuffer += '\n'
} else if (node.type === 'image') {
flushTextBuffer()
const data = {
...getImageInfo(node.attrs.src),
url: node.attrs.src
}
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
}
function tiptapToString() {
if (!editor.value) return ''
const json = editor.value.getJSON()
let result = ''
const processInlines = nodes => {
nodes.forEach(node => {
if (node.type === 'text') {
result += node.text
} else if (node.type === 'mention') {
result += `@${node.attrs.label} `
} else if (node.type === 'emoji') {
// 关键修改使用表情的alt文本而不是忽略
result += node.attrs.alt || ''
} else if (node.type === 'hardBreak') {
result += '\n'
}
})
}
if (json.content) {
json.content.forEach(node => {
if (node.type === 'paragraph') {
if (node.content) {
processInlines(node.content)
}
result += '\n'
}
})
}
return result.trim()
}
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 (uploadingImages.value.size > 0) {
return window['$message'].info('正在上传图片,请稍后再发')
}
if (!editor.value || (isEditorEmpty() && !quoteData.value)) return
const messages = tiptapToMessage()
if (messages.length === 0 && !quoteData.value) {
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
}
if (quoteData.value) {
msg.data.quoteId = quoteData.value.id
msg.data.quote = { ...quoteData.value }
}
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,
}
if (quoteData.value) {
data.quoteId = quoteData.value.id
data.quote = { ...quoteData.value }
}
emit('editor-event', emitCall('image_event', data))
}
})
if (messages.length === 0 && quoteData.value) {
const emptyData = {
items: [{ type: 1, content: '' }],
mentions: [],
mentionUids: [],
quoteId: quoteData.value.id,
quote: { ...quoteData.value }
}
emit('editor-event', emitCall('text_event', emptyData))
}
if (canClear) {
editor.value?.commands.clearContent(true)
quoteData.value = null
onEditorChange()
}
}
function onEditorChange() {
if (!editor.value) return
const text = tiptapToString()
if (!isEditorEmpty() || quoteData.value) {
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
text: text,
content: editor.value.getJSON(),
quoteData: quoteData.value
})
} else {
delete editorDraftStore.items[indexName.value || '']
}
emit('editor-event', emitCall('input_event', text))
}
function loadEditorDraftText() {
if (!editor.value) return
quoteData.value = null
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)
}
if (parsed.quoteData) {
quoteData.value = parsed.quoteData
}
} else {
editor.value.commands.clearContent(true)
}
editor.value.commands.focus('end')
}
function onSubscribeMention(data) {
if (!editor.value) return
editor.value.chain().focus().insertContent({
type: 'mention',
attrs: {
id: data?.id,
label: data.value,
},
}).run()
}
function onSubscribeQuote(data) {
if (!editor.value) return
quoteData.value = data
onEditorChange()
}
function clearQuoteData() {
quoteData.value = null
onEditorChange()
}
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>
<div v-if="quoteData" class="quote-card-wrapper">
<div class="quote-card-content">
<div class="quote-card-title">
<span>{{ quoteData.title || ' ' }}</span>
<n-icon size="18" class="quote-card-remove" :component="Close" @click="clearQuoteData" />
</div>
<div v-if="quoteData.image" class="quote-card-image">
<img :src="quoteData.image" alt="引用图片" />
</div>
<div v-if="quoteData.describe" class="quote-card-meta">
{{ quoteData.describe }}
</div>
</div>
</div>
<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%;
.quote-card-wrapper {
padding: 10px;
background-color: #fff;
}
.quote-card-content {
display: flex;
background-color: #f6f6f6;
flex-direction: column;
padding: 8px;
border-radius: 4px;
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;
align-items: center;
.quote-card-remove {
cursor: pointer;
&:hover {
color: #333;
}
}
}
.quote-card-image {
margin-top: 4px;
img {
max-width: 100px;
max-height: 60px;
border-radius: 4px;
}
}
.quote-card-meta {
margin-top: 4px;
font-size: 12px;
line-height: 20px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.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;
.quote-card-wrapper {
background-color: #1e1e1e;
}
.quote-card-content {
background-color: #2c2c2c;
.quote-card-title {
color: #e0e0e0;
.quote-card-remove {
&:hover {
color: #fff;
}
}
}
.quote-card-meta {
color: #999;
}
}
}
}
</style>
<style lang="less">
.tiptap-editor {
height: 100%;
overflow: auto;
padding: 8px;
outline: none;
.tiptap.ProseMirror{
height: 100%;
}
.image-upload-loading {
position: relative;
display: inline-block;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5) url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" style="background:0 0"><circle cx="50" cy="50" r="32" stroke-width="8" stroke="%23fff" stroke-dasharray="50.26548245743669 50.26548245743669" fill="none" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="0 50 50;360 50 50"/></svg>');
background-size: 30px 30px;
background-position: center center;
background-repeat: no-repeat;
border-radius: 5px;
}
}
&::-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: #fff;
background-color: var(--im-primary-color);
border-radius: 2px;
padding: 0 5px;
}
/* 引用卡片样式 */
.quote-card {
margin-bottom: 8px;
}
}
/* 暗色模式下的样式调整 */
html[theme-mode='dark'] {
.tiptap-editor {
.is-empty::before {
color: #57575a;
}
.quote-card-content {
background-color: var(--im-message-bg-color);
}
}
}
</style>