chat-pc/src/components/editor/Editor.vue

704 lines
16 KiB
Vue
Raw Normal View History

2024-12-24 08:14:21 +00:00
<script lang="ts" setup>
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import 'quill-image-uploader/dist/quill.imageUploader.min.css'
import '@/assets/css/editor-mention.less'
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted } from 'vue'
import { NPopover } from 'naive-ui'
import {
Voice as IconVoice,
SourceCode,
Local,
SmilingFace,
Pic,
FolderUpload,
Ranking,
History
} from '@icon-park/vue-next'
import { QuillEditor, Quill } from '@vueup/vue-quill'
import ImageUploader from 'quill-image-uploader'
import EmojiBlot from './formats/emoji'
import QuoteBlot from './formats/quote'
import 'quill-mention'
import { useDialogueStore, useEditorDraftStore } from '@/store'
import { deltaToMessage, deltaToString, isEmptyDelta } from './util'
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'
import { ServeUploadImage } from '@/api/upload'
import { uploadImg } from '@/api/upload'
import { useEventBus } from '@/hooks'
Quill.register('formats/emoji', EmojiBlot)
Quill.register('formats/quote', QuoteBlot)
Quill.register('modules/imageUploader', ImageUploader)
const emit = defineEmits(['editor-event'])
const dialogueStore = useDialogueStore()
const editorDraftStore = useEditorDraftStore()
const props = defineProps({
vote: {
type: Boolean,
default: false
},
members: {
default: () => []
}
})
const editor = ref()
const getQuill = () => {
return editor.value?.getQuill()
}
const getQuillSelectionIndex = () => {
let quill = getQuill()
return (quill.getSelection() || {}).index || quill.getLength()
}
const indexName = computed(() => dialogueStore.index_name)
const isShowEditorVote = ref(false)
const isShowEditorCode = ref(false)
const isShowEditorRecorder = ref(false)
const fileImageRef = ref()
const uploadFileRef = ref()
const emoticonRef = ref()
const editorOption = {
debug: false,
modules: {
toolbar: false,
clipboard: {
// 粘贴版,处理粘贴时候的自带样式
matchers: [[Node.ELEMENT_NODE, onClipboardMatcher]]
},
keyboard: {
bindings: {
enter: {
key: 13,
handler: onSendMessage
}
}
},
imageUploader: {
upload: onEditorUpload
},
mention: {
allowedChars: /^[\u4e00-\u9fa5]*$/,
mentionDenotationChars: ['@'],
positioningStrategy: 'fixed',
renderItem: (data: any) => {
const el = document.createElement('div')
el.className = 'ed-member-item'
el.innerHTML = `<img src="${data.avatar}" class="avator"/>`
el.innerHTML += `<span class="nickname">${data.nickname}</span>`
return el
},
source: function (searchTerm: string, renderList: any) {
if (!props.members.length) {
return renderList([])
}
let list = [
{ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' },
...props.members
]
const items = list.filter(
(item: any) => item.nickname.toLowerCase().indexOf(searchTerm) !== -1
)
renderList(items)
},
mentionContainerClass: 'ql-mention-list-container me-scrollbar me-scrollbar-thumb'
}
},
placeholder: '按Enter发送 / Shift+Enter 换行',
theme: 'snow'
}
const navs = reactive([
{
title: '图片',
icon: markRaw(Pic),
show: true,
click: () => {
fileImageRef.value.click()
}
},
{
title: '附件',
icon: markRaw(FolderUpload),
show: true,
click: () => {
uploadFileRef.value.click()
}
},
{
title: '代码',
icon: markRaw(SourceCode),
show: true,
click: () => {
isShowEditorCode.value = true
}
},
{
title: '语音消息',
icon: markRaw(IconVoice),
show: true,
click: () => {
isShowEditorRecorder.value = true
}
},
{
title: '地理位置',
icon: markRaw(Local),
show: true,
click: () => {}
},
{
title: '群投票',
icon: markRaw(Ranking),
show: computed(() => props.vote),
click: () => {
isShowEditorVote.value = true
}
},
{
title: '历史记录',
icon: markRaw(History),
show: true,
click: () => {
emit('editor-event', emitCall('history_event'))
}
}
])
function onUploadImage(file: 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('width', image.width.toString())
// form.append('height', image.height.toString())
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 onEditorUpload(file: File) {
async function fn(file: File, resolve: Function, reject: Function) {
if (file.type.indexOf('image/') === 0) {
return resolve(await onUploadImage(file))
}
reject()
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)
}
}
return new Promise((resolve, reject) => {
fn(file, resolve, reject)
})
}
function onVoteEvent(data: any) {
const msg = emitCall('vote_event', data, (ok: boolean) => {
if (ok) {
isShowEditorVote.value = false
}
})
emit('editor-event', msg)
}
function onEmoticonEvent(data: any) {
emoticonRef.value.setShow(false)
if (data.type == 1) {
const quill = getQuill()
let index = getQuillSelectionIndex()
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
quill.deleteText(0, 1)
index = 0
}
if (data.img) {
quill.insertEmbed(index, 'emoji', {
alt: data.value,
src: data.img,
width: '24px',
height: '24px'
})
} else {
quill.insertText(index, data.value)
}
quill.setSelection(index + 1, 0, 'user')
} else {
let fn = emitCall('emoticon_event', data.value, () => {})
emit('editor-event', fn)
}
}
function onCodeEvent(data: any) {
const msg = emitCall('code_event', data, (ok: boolean) => {
isShowEditorCode.value = false
})
emit('editor-event', msg)
}
async function onUploadFile(e: any) {
let file = e.target.files[0]
e.target.value = null
console.log("文件类型"+file.type)
if (file.type.indexOf('image/') === 0) {
console.log("进入图片")
const quill = getQuill()
let index = getQuillSelectionIndex()
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
quill.deleteText(0, 1)
index = 0
}
let src = await onUploadImage(file)
if (src) {
quill.insertEmbed(index, 'image', src)
quill.setSelection(index + 1)
}
return
}
if (file.type.indexOf('video/') === 0) {
console.log("进入视频")
let fn = emitCall('video_event', file, () => {})
emit('editor-event', fn)
} else {
console.log("进入其他")
let fn = emitCall('file_event', file, () => {})
emit('editor-event', fn)
}
}
function onRecorderEvent(file: any) {
emit('editor-event', emitCall('file_event', file))
isShowEditorRecorder.value = false
}
function onClipboardMatcher(node: any, Delta) {
const ops: any[] = []
Delta.ops.forEach((op) => {
// 如果粘贴了图片,这里会是一个对象,所以可以这样处理
if (op.insert && typeof op.insert === 'string') {
ops.push({
insert: op.insert, // 文字内容
attributes: {} //文字样式(包括背景色和文字颜色等)
})
} else {
ops.push(op)
}
})
Delta.ops = ops
return Delta
}
function onSendMessage() {
var delta = getQuill().getContents()
let data = deltaToMessage(delta)
if (data.items.length === 0) {
return
}
switch (data.msgType) {
case 1: // 文字消息
if (data.items[0].content.length > 1024) {
return window['$message'].info('发送内容超长,请分条发送')
}
emit(
'editor-event',
emitCall('text_event', data, (ok: any) => {
ok && getQuill().setContents([], Quill.sources.USER)
})
)
break
case 3: // 图片消息
emit(
'editor-event',
emitCall(
'image_event',
{ ...getImageInfo(data.items[0].content), url: data.items[0].content, size: 10000 },
(ok: any) => {
ok && getQuill().setContents([])
}
)
)
break
case 12: // 图文消息
emit(
'editor-event',
emitCall('mixed_event', data, (ok: any) => {
ok && getQuill().setContents([])
})
)
break
}
}
function onEditorChange() {
let delta = getQuill().getContents()
let text = deltaToString(delta)
if (!isEmptyDelta(delta)) {
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
text: text,
ops: delta.ops
})
} else {
// 删除 editorDraftStore.items 下的元素
delete editorDraftStore.items[indexName.value || '']
}
emit('editor-event', emitCall('input_event', text))
}
function loadEditorDraftText() {
if (!editor.value) return
// 这里延迟处理,不然会有问题
setTimeout(() => {
hideMentionDom()
const quill = getQuill()
if (!quill) return
// 从缓存中加载编辑器草稿
let draft = editorDraftStore.items[indexName.value || '']
if (draft) {
quill.setContents(JSON.parse(draft)?.ops || [])
} else {
quill.setContents([])
}
const index = getQuillSelectionIndex()
quill.setSelection(index, 0, 'user')
}, 0)
}
function onSubscribeMention(data: any) {
const mention = getQuill().getModule('mention')
mention.insertItem({ id: data?.id, denotationChar: '@', value: data.value }, true)
}
function onSubscribeQuote(data: any) {
const delta = getQuill().getContents()
if (delta.ops?.some((item: any) => item.insert.quote)) {
return
}
const quill = getQuill()
const index = getQuillSelectionIndex()
quill.insertEmbed(0, 'quote', data)
quill.setSelection(index + 1, 0, 'user')
}
function hideMentionDom() {
let el = document.querySelector('.ql-mention-list-container')
if (el) {
document.querySelector('body')?.removeChild(el)
}
}
watch(indexName, loadEditorDraftText, { immediate: true })
onMounted(() => {
loadEditorDraftText()
})
onUnmounted(() => {
hideMentionDom()
})
useEventBus([
{ name: EditorConst.Mention, event: onSubscribeMention },
{ name: EditorConst.Quote, event: onSubscribeQuote }
])
</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"
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>
<main class="el-main height100">
<QuillEditor
ref="editor"
id="editor"
:options="editorOption"
@editorChange="onEditorChange"
style="height: 100%; border: none"
/>
</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%;
.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;
}
}
</style>
<style lang="less">
#editor {
overflow: hidden;
}
.ql-editor {
padding: 8px;
&::-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);
}
}
}
.ql-editor.ql-blank::before {
font-family:
PingFang SC,
Microsoft YaHei,
'Alibaba PuHuiTi 2.0 45' !important;
left: 8px;
}
.ql-snow .ql-editor img {
max-width: 100px;
border-radius: 3px;
background-color: #48484d;
margin: 0px 2px;
}
.image-uploading {
display: flex;
width: 100px;
height: 100px;
background: #f5f5f5;
border-radius: 5px;
img {
filter: unset;
display: none;
}
}
.ed-emoji {
background-color: unset !important;
}
.ql-editor.ql-blank::before {
font-style: unset;
color: #b8b3b3;
}
.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;
}
}
html[theme-mode='dark'] {
.ql-editor.ql-blank::before {
color: #57575a;
}
.quote-card-content {
background-color: var(--im-message-bg-color);
}
}
</style>