feat(editor): 重构引用功能并优化图片上传处理
- 移除旧的Quote节点扩展,改为使用quoteData状态管理引用消息 - 添加图片上传状态跟踪和加载指示器 - 优化提及列表的交互和关闭行为 - 支持粘贴图片自动上传功能 - 完善编辑器草稿保存机制,包含引用数据
This commit is contained in:
parent
8be8afc675
commit
0f161de28f
@ -12,7 +12,7 @@ import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|||||||
// 引入Vue核心功能
|
// 引入Vue核心功能
|
||||||
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
||||||
// 引入Naive UI的弹出框组件
|
// 引入Naive UI的弹出框组件
|
||||||
import { NPopover } from 'naive-ui'
|
import { NPopover, NIcon } from 'naive-ui'
|
||||||
// 引入图标组件
|
// 引入图标组件
|
||||||
import {
|
import {
|
||||||
Voice as IconVoice, // 语音图标
|
Voice as IconVoice, // 语音图标
|
||||||
@ -22,7 +22,8 @@ import {
|
|||||||
Pic, // 图片图标
|
Pic, // 图片图标
|
||||||
FolderUpload, // 文件上传图标
|
FolderUpload, // 文件上传图标
|
||||||
Ranking, // 排名图标(用于投票)
|
Ranking, // 排名图标(用于投票)
|
||||||
History // 历史记录图标
|
History, // 历史记录图标
|
||||||
|
Close // 关闭图标
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
// 引入状态管理
|
// 引入状态管理
|
||||||
@ -70,6 +71,7 @@ const isShowEditorVote = ref(false)
|
|||||||
const isShowEditorCode = ref(false)
|
const isShowEditorCode = ref(false)
|
||||||
// 控制是否显示录音界面
|
// 控制是否显示录音界面
|
||||||
const isShowEditorRecorder = ref(false)
|
const isShowEditorRecorder = ref(false)
|
||||||
|
const uploadingImages = ref(new Map())
|
||||||
// 图片文件上传DOM引用
|
// 图片文件上传DOM引用
|
||||||
const fileImageRef = ref()
|
const fileImageRef = ref()
|
||||||
// 文件上传DOM引用
|
// 文件上传DOM引用
|
||||||
@ -78,6 +80,8 @@ const uploadFileRef = ref()
|
|||||||
const emoticonRef = ref()
|
const emoticonRef = ref()
|
||||||
// 表情面板显示状态
|
// 表情面板显示状态
|
||||||
const showEmoticon = ref(false)
|
const showEmoticon = ref(false)
|
||||||
|
// 引用消息数据
|
||||||
|
const quoteData = ref(null)
|
||||||
|
|
||||||
// 自定义Emoji扩展
|
// 自定义Emoji扩展
|
||||||
const Emoji = Node.create({
|
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键发送消息
|
// 创建自定义键盘处理插件,处理Enter键发送消息
|
||||||
const EnterKeyPlugin = new Plugin({
|
const EnterKeyPlugin = new Plugin({
|
||||||
@ -222,7 +157,43 @@ const CustomKeyboard = Extension.create({
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
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,
|
inline: true,
|
||||||
allowBase64: true,
|
allowBase64: true,
|
||||||
}),
|
}),
|
||||||
@ -234,6 +205,10 @@ const editor = useEditor({
|
|||||||
class: 'mention',
|
class: 'mention',
|
||||||
},
|
},
|
||||||
suggestion: {
|
suggestion: {
|
||||||
|
allowedPrefixes: null,
|
||||||
|
hideOnClickOutside: true,
|
||||||
|
hideOnKeyDown: true,
|
||||||
|
emptyQueryClass: 'is-empty-query',
|
||||||
items: ({ query }) => {
|
items: ({ query }) => {
|
||||||
if (!props.members.length) {
|
if (!props.members.length) {
|
||||||
return []
|
return []
|
||||||
@ -245,13 +220,21 @@ const editor = useEditor({
|
|||||||
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' })
|
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return list.filter(
|
const filteredItems = list.filter(
|
||||||
(item) => item.nickname.toLowerCase().includes(query.toLowerCase())
|
(item) => item.nickname.toLowerCase().includes(query.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 如果没有匹配项,返回空数组以关闭弹窗
|
||||||
|
if (filteredItems.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredItems
|
||||||
},
|
},
|
||||||
render: () => {
|
render: () => {
|
||||||
let component
|
let component
|
||||||
let popup
|
let popup
|
||||||
|
let handleClickOutside
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: (props) => {
|
onStart: (props) => {
|
||||||
@ -260,6 +243,18 @@ const editor = useEditor({
|
|||||||
popup.classList.add('ql-mention-list-container', 'me-scrollbar', 'me-scrollbar-thumb')
|
popup.classList.add('ql-mention-list-container', 'me-scrollbar', 'me-scrollbar-thumb')
|
||||||
document.body.appendChild(popup)
|
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) => {
|
props.items.forEach((item, index) => {
|
||||||
const mentionItem = document.createElement('div')
|
const mentionItem = document.createElement('div')
|
||||||
@ -297,18 +292,28 @@ const editor = useEditor({
|
|||||||
|
|
||||||
onKeyDown: (props) => {
|
onKeyDown: (props) => {
|
||||||
// 处理键盘事件
|
// 处理键盘事件
|
||||||
|
// Escape键关闭弹窗
|
||||||
if (props.event.key === 'Escape') {
|
if (props.event.key === 'Escape') {
|
||||||
popup.remove()
|
popup.remove()
|
||||||
return true
|
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
|
return false
|
||||||
},
|
},
|
||||||
|
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
// 清理
|
// 清理弹窗和事件监听器
|
||||||
if (popup) {
|
if (popup) {
|
||||||
popup.remove()
|
popup.remove()
|
||||||
|
// 移除所有可能的点击事件监听器
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -317,7 +322,6 @@ const editor = useEditor({
|
|||||||
}),
|
}),
|
||||||
Link,
|
Link,
|
||||||
Emoji,
|
Emoji,
|
||||||
Quote,
|
|
||||||
CustomKeyboard,
|
CustomKeyboard,
|
||||||
],
|
],
|
||||||
content: '',
|
content: '',
|
||||||
@ -327,6 +331,57 @@ const editor = useEditor({
|
|||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
onEditorChange()
|
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 文件对象
|
* @param file 文件对象
|
||||||
* @returns Promise,成功时返回图片URL
|
* @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) {
|
function onUploadImage(file) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let image = new Image()
|
let image = new Image()
|
||||||
@ -461,7 +538,6 @@ function tiptapToMessage() {
|
|||||||
|
|
||||||
const json = editor.value.getJSON()
|
const json = editor.value.getJSON()
|
||||||
const messages = []
|
const messages = []
|
||||||
let quoteId = null
|
|
||||||
let currentTextBuffer = ''
|
let currentTextBuffer = ''
|
||||||
let currentMentions = []
|
let currentMentions = []
|
||||||
let currentMentionUids = new Set()
|
let currentMentionUids = new Set()
|
||||||
@ -474,10 +550,6 @@ function tiptapToMessage() {
|
|||||||
mentions: [...currentMentions],
|
mentions: [...currentMentions],
|
||||||
mentionUids: Array.from(currentMentionUids)
|
mentionUids: Array.from(currentMentionUids)
|
||||||
}
|
}
|
||||||
if (quoteId) {
|
|
||||||
data.quoteId = quoteId
|
|
||||||
quoteId = null
|
|
||||||
}
|
|
||||||
messages.push({ type: 'text', data })
|
messages.push({ type: 'text', data })
|
||||||
}
|
}
|
||||||
currentTextBuffer = ''
|
currentTextBuffer = ''
|
||||||
@ -507,22 +579,12 @@ function tiptapToMessage() {
|
|||||||
...getImageInfo(node.attrs.src),
|
...getImageInfo(node.attrs.src),
|
||||||
url: node.attrs.src
|
url: node.attrs.src
|
||||||
}
|
}
|
||||||
if (quoteId) {
|
|
||||||
data.quoteId = quoteId
|
|
||||||
quoteId = null
|
|
||||||
}
|
|
||||||
messages.push({ type: 'image', data })
|
messages.push({ type: 'image', data })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json.content) {
|
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 => {
|
json.content.forEach(node => {
|
||||||
if (node.type === 'paragraph') {
|
if (node.type === 'paragraph') {
|
||||||
if (node.content) {
|
if (node.content) {
|
||||||
@ -535,10 +597,6 @@ function tiptapToMessage() {
|
|||||||
...getImageInfo(node.attrs.src),
|
...getImageInfo(node.attrs.src),
|
||||||
url: node.attrs.src
|
url: node.attrs.src
|
||||||
}
|
}
|
||||||
if (quoteId) {
|
|
||||||
data.quoteId = quoteId
|
|
||||||
quoteId = null
|
|
||||||
}
|
|
||||||
messages.push({ type: 'image', data })
|
messages.push({ type: 'image', data })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -585,11 +643,14 @@ function isEditorEmpty() {
|
|||||||
* 根据编辑器内容类型发送不同类型的消息
|
* 根据编辑器内容类型发送不同类型的消息
|
||||||
*/
|
*/
|
||||||
function onSendMessage() {
|
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()
|
const messages = tiptapToMessage()
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0 && !quoteData.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -601,6 +662,13 @@ function onSendMessage() {
|
|||||||
canClear = false
|
canClear = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加引用消息参数
|
||||||
|
if (quoteData.value) {
|
||||||
|
msg.data.quoteId = quoteData.value.id
|
||||||
|
msg.data.quote = { ...quoteData.value }
|
||||||
|
}
|
||||||
|
|
||||||
emit('editor-event', emitCall('text_event', msg.data))
|
emit('editor-event', emitCall('text_event', msg.data))
|
||||||
} else if (msg.type === 'image') {
|
} else if (msg.type === 'image') {
|
||||||
const data = {
|
const data = {
|
||||||
@ -609,12 +677,33 @@ function onSendMessage() {
|
|||||||
size: 10000,
|
size: 10000,
|
||||||
url: msg.data.url,
|
url: msg.data.url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加引用消息参数
|
||||||
|
if (quoteData.value) {
|
||||||
|
data.quoteId = quoteData.value.id
|
||||||
|
data.quote = { ...quoteData.value }
|
||||||
|
}
|
||||||
|
|
||||||
emit('editor-event', emitCall('image_event', data))
|
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) {
|
if (canClear) {
|
||||||
editor.value?.commands.clearContent(true)
|
editor.value?.commands.clearContent(true)
|
||||||
|
// 清空引用数据
|
||||||
|
quoteData.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -627,11 +716,12 @@ function onEditorChange() {
|
|||||||
|
|
||||||
const text = tiptapToString()
|
const text = tiptapToString()
|
||||||
|
|
||||||
if (!isEditorEmpty()) {
|
if (!isEditorEmpty() || quoteData.value) {
|
||||||
// 保存草稿到store
|
// 保存草稿到store
|
||||||
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
|
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
|
||||||
text: text,
|
text: text,
|
||||||
content: editor.value.getJSON()
|
content: editor.value.getJSON(),
|
||||||
|
quoteData: quoteData.value
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 编辑器为空时删除对应草稿
|
// 编辑器为空时删除对应草稿
|
||||||
@ -649,6 +739,10 @@ function onEditorChange() {
|
|||||||
function loadEditorDraftText() {
|
function loadEditorDraftText() {
|
||||||
if (!editor.value) return
|
if (!editor.value) return
|
||||||
|
|
||||||
|
// 保存当前引用数据
|
||||||
|
const currentQuoteData = quoteData.value
|
||||||
|
quoteData.value = null
|
||||||
|
|
||||||
// 从缓存中加载编辑器草稿
|
// 从缓存中加载编辑器草稿
|
||||||
let draft = editorDraftStore.items[indexName.value || '']
|
let draft = editorDraftStore.items[indexName.value || '']
|
||||||
if (draft) {
|
if (draft) {
|
||||||
@ -658,10 +752,20 @@ function loadEditorDraftText() {
|
|||||||
} else if (parsed.text) {
|
} else if (parsed.text) {
|
||||||
editor.value.commands.setContent(parsed.text)
|
editor.value.commands.setContent(parsed.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果草稿中有引用数据,恢复它
|
||||||
|
if (parsed.quoteData) {
|
||||||
|
quoteData.value = parsed.quoteData
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
editor.value.commands.clearContent(true) // 没有草稿则清空编辑器
|
editor.value.commands.clearContent(true) // 没有草稿则清空编辑器
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果有当前引用数据,优先使用它
|
||||||
|
if (currentQuoteData) {
|
||||||
|
quoteData.value = currentQuoteData
|
||||||
|
}
|
||||||
|
|
||||||
// 设置光标位置到末尾
|
// 设置光标位置到末尾
|
||||||
editor.value.commands.focus('end')
|
editor.value.commands.focus('end')
|
||||||
}
|
}
|
||||||
@ -690,26 +794,8 @@ function onSubscribeMention(data) {
|
|||||||
function onSubscribeQuote(data) {
|
function onSubscribeQuote(data) {
|
||||||
if (!editor.value) return
|
if (!editor.value) return
|
||||||
|
|
||||||
// 检查是否已有引用内容
|
// 保存引用数据
|
||||||
const json = editor.value.getJSON()
|
quoteData.value = data
|
||||||
if (json.content?.some(node => node.type === 'quote')) {
|
|
||||||
return // 已有引用则不再添加
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在编辑器开头插入引用
|
|
||||||
editor.value
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.insertContentAt(0, [
|
|
||||||
{
|
|
||||||
type: 'quote',
|
|
||||||
attrs: data
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'paragraph'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
.run()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -770,6 +856,8 @@ useEventBus([
|
|||||||
<!-- 编辑器容器 -->
|
<!-- 编辑器容器 -->
|
||||||
<section class="el-container editor">
|
<section class="el-container editor">
|
||||||
<section class="el-container is-vertical">
|
<section class="el-container is-vertical">
|
||||||
|
|
||||||
|
|
||||||
<!-- 工具栏区域 -->
|
<!-- 工具栏区域 -->
|
||||||
<header class="el-header toolbar bdr-t">
|
<header class="el-header toolbar bdr-t">
|
||||||
<div class="tools">
|
<div class="tools">
|
||||||
@ -807,7 +895,21 @@ useEventBus([
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<main class="el-main height100">
|
||||||
<editor-content :editor="editor" class="tiptap-editor" />
|
<editor-content :editor="editor" class="tiptap-editor" />
|
||||||
@ -838,11 +940,65 @@ useEventBus([
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
/* 编辑器容器样式 */
|
||||||
.editor {
|
.editor {
|
||||||
--tip-bg-color: rgb(241 241 241 / 90%); /* 提示背景颜色 */
|
--tip-bg-color: rgb(241 241 241 / 90%); /* 提示背景颜色 */
|
||||||
|
|
||||||
height: 100%;
|
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 {
|
.toolbar {
|
||||||
height: 38px;
|
height: 38px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -892,6 +1048,28 @@ useEventBus([
|
|||||||
html[theme-mode='dark'] {
|
html[theme-mode='dark'] {
|
||||||
.editor {
|
.editor {
|
||||||
--tip-bg-color: #48484d;
|
--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>
|
||||||
@ -904,6 +1082,25 @@ html[theme-mode='dark'] {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
outline: none;
|
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 {
|
&::-webkit-scrollbar {
|
||||||
width: 3px;
|
width: 3px;
|
||||||
@ -959,45 +1156,10 @@ html[theme-mode='dark'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 引用卡片样式 */
|
/* 引用卡片样式 */
|
||||||
.quote-card-wrapper {
|
.quote-card {
|
||||||
margin-bottom: 8px;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 提及列表样式 */
|
/* 提及列表样式 */
|
||||||
|
Loading…
Reference in New Issue
Block a user