feat(editor): 重构引用功能并优化图片上传处理

- 移除旧的Quote节点扩展,改为使用quoteData状态管理引用消息
- 添加图片上传状态跟踪和加载指示器
- 优化提及列表的交互和关闭行为
- 支持粘贴图片自动上传功能
- 完善编辑器草稿保存机制,包含引用数据
This commit is contained in:
Phoenix 2025-07-02 15:57:03 +08:00
parent 8be8afc675
commit 0f161de28f

View File

@ -12,7 +12,7 @@ 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 { NPopover, NIcon } from 'naive-ui'
//
import {
Voice as IconVoice, //
@ -22,7 +22,8 @@ import {
Pic, //
FolderUpload, //
Ranking, //
History //
History, //
Close //
} from '@icon-park/vue-next'
//
@ -70,6 +71,7 @@ const isShowEditorVote = ref(false)
const isShowEditorCode = ref(false)
//
const isShowEditorRecorder = ref(false)
const uploadingImages = ref(new Map())
// DOM
const fileImageRef = ref()
// DOM
@ -78,6 +80,8 @@ const uploadFileRef = ref()
const emoticonRef = ref()
//
const showEmoticon = ref(false)
//
const quoteData = ref(null)
// Emoji
const Emoji = Node.create({
@ -120,76 +124,7 @@ const Emoji = Node.create({
},
})
// 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({
@ -222,7 +157,43 @@ const CustomKeyboard = Extension.create({
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
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,
}),
@ -234,6 +205,10 @@ const editor = useEditor({
class: 'mention',
},
suggestion: {
allowedPrefixes: null,
hideOnClickOutside: true,
hideOnKeyDown: true,
emptyQueryClass: 'is-empty-query',
items: ({ query }) => {
if (!props.members.length) {
return []
@ -245,13 +220,21 @@ const editor = useEditor({
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' })
}
return list.filter(
const filteredItems = list.filter(
(item) => item.nickname.toLowerCase().includes(query.toLowerCase())
)
//
if (filteredItems.length === 0) {
return []
}
return filteredItems
},
render: () => {
let component
let popup
let handleClickOutside
return {
onStart: (props) => {
@ -260,6 +243,18 @@ const editor = useEditor({
popup.classList.add('ql-mention-list-container', 'me-scrollbar', 'me-scrollbar-thumb')
document.body.appendChild(popup)
//
handleClickOutside = (event) => {
if (popup && !popup.contains(event.target)) {
popup.remove()
document.removeEventListener('click', handleClickOutside)
}
}
// 使setTimeout
setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 100)
//
props.items.forEach((item, index) => {
const mentionItem = document.createElement('div')
@ -297,18 +292,28 @@ const editor = useEditor({
onKeyDown: (props) => {
//
// Escape
if (props.event.key === 'Escape') {
popup.remove()
return true
}
//
const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab']
if (!navigationKeys.includes(props.event.key) && props.items.length === 0) {
popup.remove()
return false
}
return false
},
onExit: () => {
//
//
if (popup) {
popup.remove()
//
document.removeEventListener('click', handleClickOutside)
}
},
}
@ -317,7 +322,6 @@ const editor = useEditor({
}),
Link,
Emoji,
Quote,
CustomKeyboard,
],
content: '',
@ -327,6 +331,57 @@ const editor = useEditor({
onUpdate: () => {
onEditorChange()
},
editorProps: {
handlePaste: (view, event) => {
const items = (event.clipboardData || event.originalEvent.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
}
}
return false
}
}
})
/**
@ -334,6 +389,28 @@ const editor = useEditor({
* @param file 文件对象
* @returns Promise成功时返回图片URL
*/
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()
@ -461,7 +538,6 @@ function tiptapToMessage() {
const json = editor.value.getJSON()
const messages = []
let quoteId = null
let currentTextBuffer = ''
let currentMentions = []
let currentMentionUids = new Set()
@ -474,10 +550,6 @@ function tiptapToMessage() {
mentions: [...currentMentions],
mentionUids: Array.from(currentMentionUids)
}
if (quoteId) {
data.quoteId = quoteId
quoteId = null
}
messages.push({ type: 'text', data })
}
currentTextBuffer = ''
@ -507,22 +579,12 @@ function tiptapToMessage() {
...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) {
@ -535,10 +597,6 @@ function tiptapToMessage() {
...getImageInfo(node.attrs.src),
url: node.attrs.src
}
if (quoteId) {
data.quoteId = quoteId
quoteId = null
}
messages.push({ type: 'image', data })
}
})
@ -585,11 +643,14 @@ function isEditorEmpty() {
* 根据编辑器内容类型发送不同类型的消息
*/
function onSendMessage() {
if (!editor.value || isEditorEmpty()) return
if (uploadingImages.value.size > 0) {
return window['$message'].info('正在上传图片,请稍后再发')
}
if (!editor.value || (isEditorEmpty() && !quoteData.value)) return
const messages = tiptapToMessage()
if (messages.length === 0) {
if (messages.length === 0 && !quoteData.value) {
return
}
@ -601,6 +662,13 @@ function onSendMessage() {
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 = {
@ -609,12 +677,33 @@ function onSendMessage() {
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
}
}
@ -627,11 +716,12 @@ function onEditorChange() {
const text = tiptapToString()
if (!isEditorEmpty()) {
if (!isEditorEmpty() || quoteData.value) {
// 稿store
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
text: text,
content: editor.value.getJSON()
content: editor.value.getJSON(),
quoteData: quoteData.value
})
} else {
// 稿
@ -649,6 +739,10 @@ function onEditorChange() {
function loadEditorDraftText() {
if (!editor.value) return
//
const currentQuoteData = quoteData.value
quoteData.value = null
// 稿
let draft = editorDraftStore.items[indexName.value || '']
if (draft) {
@ -658,10 +752,20 @@ function loadEditorDraftText() {
} else if (parsed.text) {
editor.value.commands.setContent(parsed.text)
}
// 稿
if (parsed.quoteData) {
quoteData.value = parsed.quoteData
}
} else {
editor.value.commands.clearContent(true) // 稿
}
// 使
if (currentQuoteData) {
quoteData.value = currentQuoteData
}
//
editor.value.commands.focus('end')
}
@ -690,26 +794,8 @@ function onSubscribeMention(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()
//
quoteData.value = data
}
/**
@ -770,6 +856,8 @@ useEventBus([
<!-- 编辑器容器 -->
<section class="el-container editor">
<section class="el-container is-vertical">
<!-- 工具栏区域 -->
<header class="el-header toolbar bdr-t">
<div class="tools">
@ -807,7 +895,21 @@ useEventBus([
</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="quoteData = null" />
</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" />
@ -838,11 +940,65 @@ useEventBus([
</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;
@ -892,6 +1048,28 @@ useEventBus([
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>
@ -904,6 +1082,25 @@ html[theme-mode='dark'] {
padding: 8px;
outline: none;
.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;
@ -959,45 +1156,10 @@ html[theme-mode='dark'] {
}
/* 引用卡片样式 */
.quote-card-wrapper {
.quote-card {
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;
}
}
}
/* 提及列表样式 */