From 0f161de28f1ddfa1f85327b47256a28236e34edf Mon Sep 17 00:00:00 2001 From: Phoenix <64720302+Concur-max@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:57:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E9=87=8D=E6=9E=84=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8A=E4=BC=A0=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除旧的Quote节点扩展,改为使用quoteData状态管理引用消息 - 添加图片上传状态跟踪和加载指示器 - 优化提及列表的交互和关闭行为 - 支持粘贴图片自动上传功能 - 完善编辑器草稿保存机制,包含引用数据 --- src/components/editor/TiptapEditor.vue | 476 +++++++++++++++++-------- 1 file changed, 319 insertions(+), 157 deletions(-) diff --git a/src/components/editor/TiptapEditor.vue b/src/components/editor/TiptapEditor.vue index e19da1e..77a8996 100644 --- a/src/components/editor/TiptapEditor.vue +++ b/src/components/editor/TiptapEditor.vue @@ -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') } @@ -689,27 +793,9 @@ 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([
+ +
@@ -807,7 +895,21 @@ useEventBus([
- + +
+
+
+ {{ quoteData.title || ' ' }} + +
+
+ 引用图片 +
+
+ {{ quoteData.describe }} +
+
+
@@ -838,10 +940,64 @@ useEventBus([ @@ -903,6 +1081,25 @@ html[theme-mode='dark'] { overflow: auto; 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,'); + background-size: 30px 30px; + background-position: center center; + background-repeat: no-repeat; + border-radius: 5px; + } + } /* 滚动条样式 */ &::-webkit-scrollbar { @@ -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; - } - } } /* 提及列表样式 */