1089 lines
24 KiB
Vue
1089 lines
24 KiB
Vue
<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 { IosSend } from '@vicons/ionicons4'
|
||
|
||
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
|
||
},
|
||
uid:{
|
||
type: Number
|
||
},
|
||
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: {
|
||
uid: props.uid,
|
||
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.insertContent(text).replace(/\n/g, '<br>'))
|
||
editor.value.commands.insertContent(text.replace(/\r/g, '').replace(/\n/g, '<br>'))
|
||
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) {
|
||
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
form.append('source', 'fonchain-chat')
|
||
const { data } = await uploadImg(form)
|
||
let fn = emitCall('image_event', { url: data.ori_url, size: file.size }, () => { })
|
||
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'
|
||
} else if (node.type === 'image') {
|
||
result += '[图片]'
|
||
}
|
||
})
|
||
}
|
||
|
||
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.replace(/\n/g, '<br>'))
|
||
|
||
|
||
|
||
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>
|
||
<div class="h-[45px] flex justify-center items-center">
|
||
<n-button class="w-80px h-30px mr-[22px]" type="primary" @click="onSendMessage">
|
||
<template #icon>
|
||
<n-icon>
|
||
<IosSend />
|
||
</n-icon>
|
||
</template>
|
||
发送
|
||
</n-button>
|
||
</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%;
|
||
white-space: pre-wrap;
|
||
}
|
||
.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> |