chat-pc/src/components/editor/TiptapEditor.vue
Phoenix 0f161de28f feat(editor): 重构引用功能并优化图片上传处理
- 移除旧的Quote节点扩展,改为使用quoteData状态管理引用消息
- 添加图片上传状态跟踪和加载指示器
- 优化提及列表的交互和关闭行为
- 支持粘贴图片自动上传功能
- 完善编辑器草稿保存机制,包含引用数据
2025-07-02 15:57:03 +08:00

1225 lines
31 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>
// 引入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, 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 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)
const uploadingImages = ref(new Map())
// 图片文件上传DOM引用
const fileImageRef = ref()
// 文件上传DOM引用
const uploadFileRef = ref()
// 表情面板引用
const emoticonRef = ref()
// 表情面板显示状态
const showEmoticon = ref(false)
// 引用消息数据
const quoteData = ref(null)
// 自定义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]
},
})
// 创建自定义键盘处理插件处理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.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: {
allowedPrefixes: null,
hideOnClickOutside: true,
hideOnKeyDown: true,
emptyQueryClass: 'is-empty-query',
items: ({ query }) => {
if (!props.members.length) {
return []
}
let list = [...props.members]
if ((dialogueStore.groupInfo).is_manager) {
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' })
}
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) => {
// 创建提及列表容器
popup = document.createElement('div')
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')
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) => {
// 处理键盘事件
// 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)
}
},
}
},
},
}),
Link,
Emoji,
CustomKeyboard,
],
content: '',
autofocus: true,
editable: true,
injectCSS: false,
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
}
}
})
/**
* 上传图片函数
* @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()
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 []
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' // Add newline after each paragraph
} 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
}
// 将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 (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
}
}
/**
* 编辑器内容改变时的处理
* 保存草稿并触发输入事件
*/
function onEditorChange() {
if (!editor.value) return
const text = tiptapToString()
if (!isEditorEmpty() || quoteData.value) {
// 保存草稿到store
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
// 保存当前引用数据
const currentQuoteData = quoteData.value
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) // 没有草稿则清空编辑器
}
// 如果有当前引用数据,优先使用它
if (currentQuoteData) {
quoteData.value = currentQuoteData
}
// 设置光标位置到末尾
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
// 保存引用数据
quoteData.value = data
}
/**
* 处理编辑消息事件
* @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() // 触发文件上传
}
},
])
// 监听聊天索引变化,切换聊天时加载对应草稿
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="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" />
</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;
.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: #0366d6;
background-color: rgba(3, 102, 214, 0.1);
border-radius: 2px;
padding: 0 2px;
}
/* 引用卡片样式 */
.quote-card {
margin-bottom: 8px;
}
}
/* 提及列表样式 */
.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>