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

2060 lines
49 KiB
Vue
Raw Normal View History

2025-06-05 08:21:39 +00:00
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, markRaw, watch } from 'vue'
import { NPopover, NIcon } from 'naive-ui'
import {
SmilingFace,
Pic,
FolderUpload
} from '@icon-park/vue-next'
import { bus } from '@/utils/event-bus'
import { EditorConst } from '@/constant/event-bus'
import { emitCall } from '@/utils/common'
import { deltaToMessage, deltaToString, isEmptyDelta } from './util'
import { useDialogueStore, useEditorDraftStore } from '@/store'
import { uploadImg } from '@/api/upload'
import { defAvatar } from '@/constant/default'
import { getImageInfo } from '@/utils/functions'
import MeEditorEmoticon from './MeEditorEmoticon.vue'
import {IosSend} from '@vicons/ionicons4'
2025-06-05 08:21:39 +00:00
const props = defineProps({
vote: {
type: Boolean,
default: false
},
members: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: 'Enter-发送消息 [Ctrl+Enter/Shift+Enter]-换行'
2025-06-05 08:21:39 +00:00
}
})
const emit = defineEmits(['editor-event'])
const editorRef = ref(null)
const content = ref('')
const isFocused = ref(false)
const showMention = ref(false)
const mentionQuery = ref('')
const mentionPosition = ref({ top: 0, left: 0 })
const selectedMentionIndex = ref(0)
const quoteData = ref(null)
const editingMessage = ref(null)
const isShowEmoticon = ref(false)
const fileImageRef = ref(null)
const uploadFileRef = ref(null)
const emoticonRef = ref(null)
const dialogueStore = useDialogueStore()
const editorDraftStore = useEditorDraftStore()
2025-06-05 08:21:39 +00:00
const indexName = computed(() => dialogueStore.index_name)
const navs = ref([
2025-06-05 08:21:39 +00:00
{
title: '图片',
icon: markRaw(Pic),
show: true,
click: () => {
fileImageRef.value.click()
}
},
{
title: '文件',
icon: markRaw(FolderUpload),
show: true,
click: () => {
uploadFileRef.value.click()
}
}
])
const mentionList = ref([])
const currentMentionQuery = ref('')
setTimeout(() => {
console.log('props.members',props.members)
}, 1000)
2025-06-05 08:21:39 +00:00
const editorContent = ref('')
const editorHtml = ref('')
2025-06-05 08:21:39 +00:00
const toolbarConfig = computed(() => {
const config = [
{ type: 'emoticon', icon: 'icon-biaoqing', title: '表情' },
{ type: 'image', icon: 'icon-image', title: '图片' },
{ type: 'file', icon: 'icon-folder-plus', title: '文件' }
]
return config
})
2025-06-05 08:21:39 +00:00
const handleInput = (event) => {
const target = event.target
2025-06-05 08:21:39 +00:00
const editorClone = target.cloneNode(true)
const quoteElements = editorClone.querySelectorAll('.editor-quote')
quoteElements.forEach(quote => quote.remove())
quoteElements.forEach(quote => quote.remove())
2025-06-05 08:21:39 +00:00
2025-06-05 08:21:39 +00:00
const emojiImages = editorClone.querySelectorAll('img.editor-emoji')
let textContent = editorClone.textContent || ''
if (emojiImages.length > 0) {
emojiImages.forEach(emoji => {
const altText = emoji.getAttribute('alt')
if (altText) {
textContent += altText
}
})
}
2025-06-05 08:21:39 +00:00
2025-06-05 08:21:39 +00:00
editorContent.value = textContent
const isEmpty = textContent === '' &&
!target.querySelector('img, .editor-file, .mention')
2025-06-05 08:21:39 +00:00
if (isEmpty && target.innerHTML !== '') {
target.innerHTML = ''
}
editorHtml.value = target.innerHTML || ''
const currentEditor= parseEditorContent().items
2025-06-05 08:21:39 +00:00
checkMention(target)
saveDraft()
2025-06-05 08:21:39 +00:00
emit('editor-event', {
event: 'input_event',
data: currentEditor.reduce((result, x) => {
if (x.type === 3) return result + '[图片]'
if (x.type === 1) return result + x.content
return result
}, '')
2025-06-05 08:21:39 +00:00
})
}
2025-06-05 08:21:39 +00:00
const checkMention = (target) => {
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
const textBeforeCursor = range.startContainer.textContent?.substring(0, range.startOffset) || ''
const mentionMatch = textBeforeCursor.match(/@([^@\s]*)$/)
if (mentionMatch) {
currentMentionQuery.value = mentionMatch[1]
showMentionList()
updateMentionPosition(range)
} else {
hideMentionList()
}
}
2025-06-05 08:21:39 +00:00
const showMentionList = () => {
const query = currentMentionQuery.value.toLowerCase()
mentionList.value = props.members.filter(member => {
return member.value.toLowerCase().startsWith(query)
})
if(dialogueStore.groupInfo.is_manager){
mentionList.value.unshift({ id: 0, nickname: '全体成员', avatar: defAvatar, value: '全体成员' })
}
2025-06-05 08:21:39 +00:00
showMention.value = mentionList.value.length > 0
selectedMentionIndex.value = 0
}
const handleMentionSelectByMouse = (member) => {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
insertMention(member, selection.getRangeAt(0).cloneRange());
} else {
editorRef.value?.focus();
nextTick(() => {
const newSelection = window.getSelection();
if (newSelection && newSelection.rangeCount > 0) {
insertMention(member, newSelection.getRangeAt(0).cloneRange());
}
});
}
};
2025-06-05 08:21:39 +00:00
const hideMentionList = () => {
showMention.value = false
mentionList.value = []
currentMentionQuery.value = ''
}
2025-06-05 08:21:39 +00:00
const updateMentionPosition = (range) => {
const rect = range.getBoundingClientRect()
const editorRect = editorRef.value.getBoundingClientRect()
mentionPosition.value = {
top: rect.bottom - editorRect.top + 5,
left: rect.left - editorRect.left
}
}
const insertMention = (member, clonedRange) => {
console.log('插入mention', member);
const selection = window.getSelection();
if (!clonedRange || !selection) return;
const range = clonedRange;
const textNode = range.startContainer;
const offset = range.startOffset;
const textContent = textNode.nodeType === Node.TEXT_NODE ? textNode.textContent || '' : '';
const atIndex = (textNode.nodeType === Node.TEXT_NODE && offset > 0) ? textContent.lastIndexOf('@', offset - 1) : -1;
const mentionSpan = document.createElement('span');
mentionSpan.className = 'mention';
mentionSpan.setAttribute('data-user-id', String(member.id));
mentionSpan.textContent = `@${member.value || member.nickname}`;
mentionSpan.contentEditable = 'false';
if (atIndex !== -1 && textNode.nodeType === Node.TEXT_NODE) {
const parent = textNode.parentNode;
if (!parent) return;
range.setStart(textNode, atIndex);
range.setEnd(textNode, offset);
range.deleteContents();
range.insertNode(mentionSpan);
2025-06-05 08:21:39 +00:00
} else {
if (!range.collapsed) {
range.deleteContents();
}
range.insertNode(mentionSpan);
}
const spaceNode = document.createTextNode('\u00A0');
const currentParent = mentionSpan.parentNode;
if (currentParent) {
if (mentionSpan.nextSibling) {
currentParent.insertBefore(spaceNode, mentionSpan.nextSibling);
} else {
currentParent.appendChild(spaceNode);
}
range.setStartAfter(spaceNode);
range.collapse(true);
} else {
range.setStartAfter(mentionSpan);
range.collapse(true);
2025-06-05 08:21:39 +00:00
}
selection.removeAllRanges();
selection.addRange(range);
editorRef.value?.focus();
nextTick(() => {
handleInput({ target: editorRef.value });
hideMentionList();
});
};
2025-06-05 08:21:39 +00:00
2025-06-05 08:21:39 +00:00
const handlePaste = (event) => {
event.preventDefault()
const items = event.clipboardData?.items
if (items) {
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile()
if (file) {
const tempUrl = URL.createObjectURL(file);
const image = new Image();
image.src = tempUrl;
image.onload = () => {
const form = new FormData();
form.append('file', file);
form.append("source", "fonchain-chat");
form.append("urlParam", `width=${image.width}&height=${image.height}`);
insertImage(tempUrl, image.width, image.height);
uploadImg(form).then(({ code, data, message }) => {
if (code == 0) {
const editorImages = editorRef.value.querySelectorAll('img.editor-image');
const lastImage = editorImages[editorImages.length - 1];
if (lastImage && lastImage.src === tempUrl) {
lastImage.src = data.ori_url;
handleInput({ target: editorRef.value });
}
} else {
window['$message'].error(message);
}
});
};
return
}
}
}
}
2025-06-05 08:21:39 +00:00
const text = event.clipboardData?.getData('text/plain') || ''
if (text) {
2025-06-05 08:21:39 +00:00
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
range.deleteContents()
range.insertNode(document.createTextNode(text))
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
2025-06-05 08:21:39 +00:00
handleInput({ target: editorRef.value })
}
}
}
2025-06-05 08:21:39 +00:00
const handleKeydown = (event) => {
2025-06-05 08:21:39 +00:00
if (showMention.value) {
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
selectedMentionIndex.value = Math.max(0, selectedMentionIndex.value - 1)
nextTick(() => {
const mentionList = document.querySelector('.mention-list ul')
const selectedItem = mentionList?.children[selectedMentionIndex.value]
if (mentionList && selectedItem && selectedItem.offsetTop < mentionList.scrollTop) {
mentionList.scrollTop = selectedItem.offsetTop
}
})
2025-06-05 08:21:39 +00:00
break
case 'ArrowDown':
event.preventDefault()
selectedMentionIndex.value = Math.min(mentionList.value.length - 1, selectedMentionIndex.value + 1)
nextTick(() => {
const mentionList = document.querySelector('.mention-list ul')
const selectedItem = mentionList?.children[selectedMentionIndex.value]
if (mentionList && selectedItem) {
const itemBottom = selectedItem.offsetTop + selectedItem.offsetHeight
const listBottom = mentionList.scrollTop + mentionList.clientHeight
if (itemBottom > listBottom) {
mentionList.scrollTop = itemBottom - mentionList.clientHeight
}
}
})
2025-06-05 08:21:39 +00:00
break
case 'Enter':
case 'Tab':
event.preventDefault();
const selectedMember = mentionList.value[selectedMentionIndex.value];
if (selectedMember) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
insertMention(selectedMember, selection.getRangeAt(0).cloneRange());
}
2025-06-05 08:21:39 +00:00
}
break;
2025-06-05 08:21:39 +00:00
case 'Escape':
hideMentionList()
break
}
return
}
if (event.key === 'Backspace' || event.key === 'Delete') {
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
const editor = editorRef.value
if (range.collapsed) {
let targetMention = null
const container = range.startContainer
const offset = range.startOffset
if (event.key === 'Backspace') {
if (container.nodeType === Node.TEXT_NODE) {
if (offset === 0) {
let prevSibling = container.previousSibling
while (prevSibling) {
if (prevSibling.nodeType === Node.ELEMENT_NODE &&
prevSibling.classList &&
prevSibling.classList.contains('mention')) {
targetMention = prevSibling
break
}
if (prevSibling.nodeType === Node.TEXT_NODE && prevSibling.textContent.trim()) {
break
}
prevSibling = prevSibling.previousSibling
}
} else {
if (!container.textContent.trim()) {
let prevSibling = container.previousSibling
while (prevSibling) {
if (prevSibling.nodeType === Node.ELEMENT_NODE &&
prevSibling.classList &&
prevSibling.classList.contains('mention')) {
targetMention = prevSibling
break
}
if (prevSibling.nodeType === Node.TEXT_NODE && prevSibling.textContent.trim()) {
break
}
prevSibling = prevSibling.previousSibling
}
}
}
} else if (container.nodeType === Node.ELEMENT_NODE) {
if (offset > 0) {
let prevChild = container.childNodes[offset - 1]
if (prevChild && prevChild.nodeType === Node.ELEMENT_NODE &&
prevChild.classList && prevChild.classList.contains('mention')) {
targetMention = prevChild
}
else if (prevChild && prevChild.nodeType === Node.TEXT_NODE && !prevChild.textContent.trim()) {
let prevSibling = prevChild.previousSibling
while (prevSibling) {
if (prevSibling.nodeType === Node.ELEMENT_NODE &&
prevSibling.classList &&
prevSibling.classList.contains('mention')) {
targetMention = prevSibling
break
}
if (prevSibling.nodeType === Node.TEXT_NODE && prevSibling.textContent.trim()) {
break
}
prevSibling = prevSibling.previousSibling
}
}
} else if (offset === 0 && container === editor) {
const firstChild = container.firstChild
if (firstChild && firstChild.nodeType === Node.ELEMENT_NODE &&
firstChild.classList && firstChild.classList.contains('mention')) {
targetMention = firstChild
}
}
}
} else if (event.key === 'Delete') {
if (container.nodeType === Node.TEXT_NODE) {
if (offset === container.textContent.length) {
let nextSibling = container.nextSibling
while (nextSibling) {
if (nextSibling.nodeType === Node.ELEMENT_NODE &&
nextSibling.classList &&
nextSibling.classList.contains('mention')) {
targetMention = nextSibling
break
}
if (nextSibling.nodeType === Node.TEXT_NODE && nextSibling.textContent.trim()) {
break
}
nextSibling = nextSibling.nextSibling
}
}
} else if (container.nodeType === Node.ELEMENT_NODE) {
if (offset < container.childNodes.length) {
const nextChild = container.childNodes[offset]
if (nextChild && nextChild.nodeType === Node.ELEMENT_NODE &&
nextChild.classList && nextChild.classList.contains('mention')) {
targetMention = nextChild
}
}
}
}
if (targetMention) {
event.preventDefault()
targetMention.remove()
handleInput({ target: editor })
return
}
}
}
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey || event.shiftKey)) {
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const br = document.createElement('br')
range.deleteContents()
range.insertNode(br)
const textNode = document.createTextNode('')
range.setStartAfter(br)
range.insertNode(textNode)
range.setStartAfter(textNode)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
handleInput({ target: editorRef.value })
}
event.preventDefault()
return
}
2025-06-05 08:21:39 +00:00
if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
event.preventDefault()
const editor = editorRef.value
const quoteElement = editor?.querySelector('.editor-quote')
if (!quoteElement && quoteData.value) {
quoteData.value = null
2025-06-05 08:21:39 +00:00
}
sendMessage()
2025-06-05 08:21:39 +00:00
}
}
2025-06-05 08:21:39 +00:00
const sendMessage = () => {
2025-06-05 08:21:39 +00:00
const messageData = parseEditorContent()
if (messageData.items.length === 0 ||
(messageData.items.length === 1 &&
messageData.items[0].type === 1 &&
!messageData.items[0].content.trimEnd())) {
return
}
function cleanInvisibleChars(text) {
return text.replace(/[\u200B-\u200D\uFEFF]/g, '');
}
messageData.items.forEach(item => {
if (item.type === 1 && cleanInvisibleChars(item.content.trimEnd())) {
const data = {
items: [{
content: cleanInvisibleChars(item.content),
type: 1
}],
mentionUids: messageData.mentionUids,
mentions: messageData.mentionUids.map(uid => {
return {
atid: uid,
name: mentionList.value.find(member => member.id === uid)?.nickname || ''
}
}),
quoteId: messageData.quoteId,
2025-06-05 08:21:39 +00:00
}
console.log('data',data)
emit(
'editor-event',
emitCall('text_event', data)
)
} else if (item.type === 3) {
const data = {
height: 0,
width: 0,
size: 10000,
url: item.content,
}
console.log('图片消息data',data)
emit(
'editor-event',
emitCall('image_event', data)
)
} else if (item.type === 4) {
}
})
clearEditor()
2025-06-05 08:21:39 +00:00
}
2025-06-05 08:21:39 +00:00
const parseEditorContent = () => {
const items = []
const mentionUids = []
2025-06-05 08:21:39 +00:00
const tempDiv = document.createElement('div')
tempDiv.innerHTML = editorHtml.value
const quoteElements = tempDiv.querySelectorAll('.editor-quote')
const hasQuote = quoteElements.length > 0 && quoteData.value
const quoteId = hasQuote ? quoteData.value.id || '' : ''
quoteElements.forEach(quote => quote.remove())
2025-06-05 08:21:39 +00:00
let textContent = ''
const processNode = (node) => {
2025-06-05 08:21:39 +00:00
if (node.nodeType === Node.TEXT_NODE) {
2025-06-05 08:21:39 +00:00
textContent += node.textContent
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
if (node.classList.contains('mention')) {
const userId = node.getAttribute('data-user-id')
if (userId) {
mentionUids.push(Number(userId))
}
textContent += node.textContent
} else if (node.tagName === 'IMG') {
processImage(node)
} else if (node.classList.contains('emoji')) {
textContent += node.getAttribute('alt') || node.textContent
} else if (node.classList.contains('editor-file')) {
processFile(node)
} else if (node.childNodes.length) {
Array.from(node.childNodes).forEach(processNode)
} else {
textContent += node.textContent
}
}
const processImage = (node) => {
const src = node.getAttribute('src')
const width = node.getAttribute('data-original-width') || node.getAttribute('width') || ''
const height = node.getAttribute('data-original-height') || node.getAttribute('height') || ''
const isEmoji = node.classList.contains('editor-emoji')
if (textContent.trim()) {
items.push({
type: 1,
content: textContent.trimEnd()
})
textContent = ''
}
if (isEmoji) {
const altText = node.getAttribute('alt') || ''
if (altText) {
textContent += altText
2025-06-05 08:21:39 +00:00
} else {
items.push({
type: 3,
content: src + (width && height ? `?width=${width}&height=${height}` : ''),
isEmoji: true
})
2025-06-05 08:21:39 +00:00
}
} else {
items.push({
type: 3,
content: src + (width && height ? `?width=${width}&height=${height}` : ''),
width: width,
height: height
})
2025-06-05 08:21:39 +00:00
}
}
const processFile = (node) => {
const fileUrl = node.getAttribute('data-url')
const fileName = node.getAttribute('data-name')
const fileSize = node.getAttribute('data-size')
if (textContent.trim()) {
items.push({
type: 1,
content: textContent.trimEnd()
})
textContent = ''
}
if (fileUrl && fileName) {
items.push({
type: 4,
content: fileUrl,
name: fileName,
size: node.getAttribute('data-size-raw') || fileSize || 0
})
}
}
Array.from(tempDiv.childNodes).forEach(processNode)
2025-06-05 08:21:39 +00:00
2025-06-05 08:21:39 +00:00
if (textContent.trim()) {
items.push({
type: 1,
content: textContent.trimEnd()
2025-06-05 08:21:39 +00:00
})
}
return {
2025-06-05 08:21:39 +00:00
items: items.length > 0 ? items : [{ type: 1, content: '' }],
mentionUids,
quoteId
2025-06-05 08:21:39 +00:00
}
}
2025-06-05 08:21:39 +00:00
const clearEditor = () => {
2025-06-05 08:21:39 +00:00
editorContent.value = ''
editorHtml.value = ''
quoteData.value = null
2025-06-05 08:21:39 +00:00
if (editorRef.value) {
editorRef.value.innerHTML = ''
nextTick(() => editorRef.value.focus())
2025-06-05 08:21:39 +00:00
}
2025-06-05 08:21:39 +00:00
hideMentionList()
2025-06-05 08:21:39 +00:00
saveDraft()
2025-06-05 08:21:39 +00:00
emit('editor-event', {
event: 'input_event',
data: ''
})
}
const insertImage = (src, width, height) => {
2025-06-05 08:21:39 +00:00
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
2025-06-05 08:21:39 +00:00
const img = document.createElement('img')
img.src = src
img.className = 'editor-image'
img.alt = '图片'
img.style.maxHeight = '150px'
img.style.maxWidth = '150px'
img.style.objectFit = 'contain'
if (width) img.setAttribute('data-original-width', width)
if (height) img.setAttribute('data-original-height', height)
2025-06-05 08:21:39 +00:00
range.deleteContents()
range.insertNode(img)
range.setStartAfter(img)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
editorRef.value.focus()
handleInput({ target: editorRef.value })
}
2025-06-05 08:21:39 +00:00
const formatFileSize = (size) => {
if (size < 1024) {
return size + ' B'
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB'
} else if (size < 1024 * 1024 * 1024) {
return (size / (1024 * 1024)).toFixed(2) + ' MB'
} else {
return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
}
}
const onUploadSendImg=async (eventFile)=>{
for (const file of eventFile.target.files) {
const form = new FormData();
form.append('file', file);
form.append("source", "fonchain-chat");
const res=await uploadImg(form)
if(res.status===0){
const data={
height:0,
width:0,
size:10000,
url:res.data.ori_url,
}
emit(
'editor-event',
emitCall(
'image_event',
data
)
)
}
}
}
async function onUploadFile(e) {
const file = e.target.files[0]
if (!file) return
e.target.value = null
2025-06-05 08:21:39 +00:00
if (file.type.indexOf('image/') === 0) {
emit('editor-event', emitCall('image_event', file))
return
2025-06-05 08:21:39 +00:00
}
if (file.type.indexOf('video/') === 0) {
emit('editor-event', emitCall('video_event', file))
2025-06-05 08:21:39 +00:00
} else {
emit('editor-event', emitCall('file_event', file))
2025-06-05 08:21:39 +00:00
}
}
2025-06-05 08:21:39 +00:00
const onEmoticonEvent = (emoji) => {
2025-06-05 08:21:39 +00:00
emoticonRef.value?.setShow(false)
switch (emoji.type) {
case 'text':
case 'emoji':
2025-06-05 08:21:39 +00:00
insertTextEmoji(emoji.value)
break
case 'image':
insertImageEmoji(emoji.img, emoji.value)
break
case 1:
emoji.img ? insertImageEmoji(emoji.img, emoji.value) : insertTextEmoji(emoji.value)
break
default:
emit('editor-event', {
event: 'emoticon_event',
data: emoji.value || emoji.id
})
break
2025-06-05 08:21:39 +00:00
}
}
2025-06-05 08:21:39 +00:00
const insertTextEmoji = (emojiText) => {
const editor = editorRef.value
if (!editor) return
editor.focus()
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
range.deleteContents()
const textNode = document.createTextNode(emojiText)
range.insertNode(textNode)
2025-06-05 08:21:39 +00:00
range.setStartAfter(textNode)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
2025-06-05 08:21:39 +00:00
handleInput({ target: editor })
}
2025-06-05 08:21:39 +00:00
const insertImageEmoji = (imgSrc, altText) => {
const editor = editorRef.value
if (!editor) return
editor.focus()
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
range.deleteContents()
const img = document.createElement('img')
img.src = imgSrc
img.alt = altText
img.className = 'editor-emoji'
2025-06-05 08:21:39 +00:00
range.insertNode(img)
2025-06-05 08:21:39 +00:00
range.setStartAfter(img)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
2025-06-05 08:21:39 +00:00
handleInput({ target: editor })
}
const onSubscribeMention = async (data) => {
const editorNode = editorRef.value;
if (!editorNode) return;
editorNode.focus();
await nextTick();
let selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
const range = document.createRange();
if (editorNode.lastChild) {
range.setStartAfter(editorNode.lastChild);
} else {
range.setStart(editorNode, 0);
}
range.collapse(true);
if (selection) selection.removeAllRanges();
selection?.addRange(range);
await nextTick();
selection = window.getSelection();
} else if (!editorNode.contains(selection.anchorNode)) {
const range = document.createRange();
if (editorNode.lastChild) {
range.setStartAfter(editorNode.lastChild);
} else {
range.setStart(editorNode, 0);
}
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
await nextTick();
selection = window.getSelection();
}
if (selection && selection.rangeCount > 0) {
insertMention(data, selection.getRangeAt(0).cloneRange());
}
};
2025-06-05 08:21:39 +00:00
const onSubscribeQuote = (data) => {
2025-06-05 08:21:39 +00:00
quoteData.value = data
2025-06-05 08:21:39 +00:00
const editor = editorRef.value
if (!editor) return
2025-06-05 08:21:39 +00:00
const existingQuotes = editor.querySelectorAll('.editor-quote')
existingQuotes.forEach(quote => quote.remove())
2025-06-05 08:21:39 +00:00
const selection = window.getSelection()
const savedRange = selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null
const hasContent = editor.textContent.trim().length > 0
2025-06-05 08:21:39 +00:00
const quoteElement = document.createElement('div')
quoteElement.className = 'editor-quote'
quoteElement.contentEditable = 'false'
2025-06-05 08:21:39 +00:00
quoteElement.innerHTML = `
<div class="quote-content-wrapper">
<div class="quote-title">${data.title}</div>
${data.image ? `<div class="quote-image"><img src="${data.image}" alt="引用图片" /></div>` : ''}
${data.describe ? `<div class="quote-content">${data.describe}</div>` : ''}
</div>
<div class="quote-close">×</div>
`
2025-06-05 08:21:39 +00:00
if (editor.firstChild) {
editor.insertBefore(quoteElement, editor.firstChild)
} else {
editor.appendChild(quoteElement)
}
quoteElement.addEventListener('click', (e) => {
const closeButton = e.target.classList?.contains('quote-close') ? e.target : e.target.closest('.quote-close')
e.stopPropagation()
if (closeButton) {
2025-06-05 08:21:39 +00:00
quoteElement.remove()
quoteData.value = null
2025-06-05 08:21:39 +00:00
editorContent.value = editor.textContent || ''
editorHtml.value = editor.innerHTML || ''
nextTick(() => editor.focus())
} else {
2025-06-05 08:21:39 +00:00
const selection = window.getSelection()
const range = document.createRange()
range.setStartAfter(quoteElement)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
2025-06-05 08:21:39 +00:00
editor.focus()
}
})
2025-06-05 08:21:39 +00:00
const handleDeleteQuote = function(e) {
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
2025-06-05 08:21:39 +00:00
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const quoteElement = editor.querySelector('.editor-quote');
if (!quoteElement) {
2025-06-05 08:21:39 +00:00
editor.removeEventListener('keydown', handleDeleteQuote);
return;
}
const quoteIndex = Array.from(editor.childNodes).indexOf(quoteElement);
2025-06-05 08:21:39 +00:00
const isBeforeQuote = e.key === 'Backspace' &&
range.collapsed &&
range.startContainer === editor &&
quoteIndex === range.startOffset;
2025-06-05 08:21:39 +00:00
const isAfterQuote = e.key === 'Delete' &&
range.collapsed &&
range.startContainer === editor &&
quoteIndex === range.startOffset - 1;
2025-06-05 08:21:39 +00:00
if (isBeforeQuote || isAfterQuote) {
e.preventDefault();
2025-06-05 08:21:39 +00:00
quoteElement.remove();
quoteData.value = null;
2025-06-05 08:21:39 +00:00
handleInput({ target: editor });
}
};
2025-06-05 08:21:39 +00:00
editor.addEventListener('keydown', handleDeleteQuote);
2025-06-05 08:21:39 +00:00
setTimeout(() => {
2025-06-05 08:21:39 +00:00
if (!editor.childNodes.length || (editor.childNodes.length === 1 && editor.childNodes[0] === quoteElement)) {
const textNode = document.createTextNode('\u200B');
2025-06-05 08:21:39 +00:00
editor.appendChild(textNode);
}
2025-06-05 08:21:39 +00:00
const currentHtml = editor.innerHTML;
editor.innerHTML = currentHtml;
2025-06-05 08:21:39 +00:00
const newQuoteElement = editor.querySelector('.editor-quote');
editor.focus();
const newSelection = window.getSelection();
const newRange = document.createRange();
2025-06-05 08:21:39 +00:00
if (newQuoteElement) {
2025-06-05 08:21:39 +00:00
let nextNode = newQuoteElement.nextSibling;
if (nextNode && nextNode.nodeType === 3) {
2025-06-05 08:21:39 +00:00
newRange.setStart(nextNode, 0);
} else if (nextNode) {
2025-06-05 08:21:39 +00:00
newRange.setStartBefore(nextNode);
} else {
2025-06-05 08:21:39 +00:00
newRange.setStartAfter(newQuoteElement);
}
} else {
2025-06-05 08:21:39 +00:00
if (editor.firstChild) {
newRange.setStartBefore(editor.firstChild);
} else {
newRange.setStart(editor, 0);
}
}
newRange.collapse(true);
newSelection.removeAllRanges();
newSelection.addRange(newRange);
2025-06-05 08:21:39 +00:00
editor.scrollTop = editor.scrollHeight;
2025-06-05 08:21:39 +00:00
handleInput({ target: editor });
2025-06-05 08:21:39 +00:00
setTimeout(() => {
editor.focus();
2025-06-05 08:21:39 +00:00
if (newSelection.rangeCount === 0) {
const finalRange = document.createRange();
if (newQuoteElement) {
finalRange.setStartAfter(newQuoteElement);
} else {
finalRange.setStart(editor, 0);
}
finalRange.collapse(true);
newSelection.removeAllRanges();
newSelection.addRange(finalRange);
}
}, 50);
}, 200);
2025-06-05 08:21:39 +00:00
}
const onSubscribeEdit = (data) => {
editingMessage.value = data
clearEditor()
2025-06-05 08:21:39 +00:00
if (data.content) {
editorRef.value.innerHTML = data.content
editorContent.value = data.content
editorHtml.value = data.content
}
}
2025-06-05 08:21:39 +00:00
const onSubscribeClear = () => {
clearEditor()
}
2025-06-05 08:21:39 +00:00
const saveDraft = () => {
if (!indexName.value || !editorRef.value) return
2025-06-05 08:21:39 +00:00
const fragment = document.createDocumentFragment()
const tempDiv = document.createElement('div')
tempDiv.innerHTML = editorRef.value.innerHTML
fragment.appendChild(tempDiv)
2025-06-05 08:21:39 +00:00
const quoteElements = tempDiv.querySelectorAll('.editor-quote')
quoteElements.forEach(quote => quote.remove())
const contentToSave = tempDiv.textContent || ''
const htmlToSave = tempDiv.innerHTML || ''
const currentEditor= parseEditorContent().items
const hasContent = contentToSave.trim().length > 0 ||
htmlToSave.includes('<img') ||
htmlToSave.includes('editor-file')
2025-06-05 08:21:39 +00:00
if (currentEditor.length>0) {
2025-06-05 08:21:39 +00:00
editorDraftStore.items[indexName.value] = JSON.stringify({
content: currentEditor.reduce((result, x) => {
if (x.type === 3) return result + '[图片]'
if (x.type === 1) return result + x.content
return result
}, ''),
2025-06-05 08:21:39 +00:00
html: htmlToSave
})
} else {
2025-06-05 08:21:39 +00:00
delete editorDraftStore.items[indexName.value]
}
}
2025-06-05 08:21:39 +00:00
const loadDraft = () => {
if (!indexName.value) return
nextTick(() => {
2025-06-05 08:21:39 +00:00
const currentQuoteData = quoteData.value
2025-06-05 08:21:39 +00:00
quoteData.value = null
if (!editorRef.value) return
editorRef.value.innerHTML = ''
editorContent.value = ''
editorHtml.value = ''
2025-06-05 08:21:39 +00:00
const draft = editorDraftStore.items[indexName.value]
2025-06-05 08:21:39 +00:00
if (draft) {
try {
const draftData = JSON.parse(draft)
editorRef.value.innerHTML = draftData.html || ''
editorContent.value = draftData.content || ''
editorHtml.value = draftData.html || ''
2025-06-05 08:21:39 +00:00
} catch (error) {
console.warn('加载草稿失败,使用空内容', error)
2025-06-05 08:21:39 +00:00
}
}
if (currentQuoteData) {
onSubscribeQuote(currentQuoteData)
}
})
2025-06-05 08:21:39 +00:00
}
2025-06-05 08:21:39 +00:00
watch(indexName, loadDraft, { immediate: true })
const handleDocumentClick = (event) => {
if (!editorRef.value?.contains(event.target)) {
hideMentionList()
}
}
2025-06-05 08:21:39 +00:00
onMounted(() => {
const subscriptions = [
[EditorConst.Mention, onSubscribeMention],
[EditorConst.Quote, onSubscribeQuote],
[EditorConst.Edit, onSubscribeEdit],
[EditorConst.Clear, onSubscribeClear]
]
2025-06-05 08:21:39 +00:00
subscriptions.forEach(([event, handler]) => {
bus.subscribe(event, handler)
2025-06-05 08:21:39 +00:00
})
editorRef.value?.addEventListener('click', handleEditorClick)
document.addEventListener('click', handleDocumentClick)
2025-06-05 08:21:39 +00:00
2025-06-05 08:21:39 +00:00
loadDraft()
})
/**
* 组件生命周期钩子 - 组件卸载前
* 清理所有事件订阅和监听器防止内存泄漏
2025-06-05 08:21:39 +00:00
*/
onBeforeUnmount(() => {
const subscriptions = [
[EditorConst.Mention, onSubscribeMention],
[EditorConst.Quote, onSubscribeQuote],
[EditorConst.Edit, onSubscribeEdit],
[EditorConst.Clear, onSubscribeClear]
]
subscriptions.forEach(([event, handler]) => {
bus.unsubscribe(event, handler)
})
editorRef.value?.removeEventListener('click', handleEditorClick)
2025-06-05 08:21:39 +00:00
document.removeEventListener('click', handleDocumentClick)
2025-06-05 08:21:39 +00:00
const editor = editorRef.value
if (editor && handleDeleteQuote) {
editor.removeEventListener('keydown', handleDeleteQuote)
}
})
/**
* 表情选择事件处理函数
*
* @param {Object} emoji - 选中的表情对象
*
* 当用户从表情选择器中选择表情时触发
* 调用onEmoticonEvent插入表情到编辑器
* 然后关闭表情选择器面板
*/
const onEmoticonSelect = (emoji) => {
2025-06-05 08:21:39 +00:00
onEmoticonEvent(emoji)
isShowEmoticon.value = false
}
/**
* 代码块提交事件处理函数
*
* @param {Object} data - 代码块数据
*
* 当用户提交代码块时触发
* 通过emit向父组件发送code_event事件
* 传递代码块数据并关闭代码块面板
*/
const onCodeSubmit = (data) => {
emit('editor-event', {
event: 'code_event',
data,
callBack: () => {}
})
isShowCode.value = false
}
/**
* 投票提交事件处理函数
*
* @param {Object} data - 投票数据
*
* 当用户提交投票时触发
* 通过emit向父组件发送vote_event事件
* 传递投票数据并关闭投票面板
*/
const onVoteSubmit = (data) => {
emit('editor-event', {
event: 'vote_event',
data,
callBack: () => {}
})
isShowVote.value = false
}
const handleEditorClick = (event) => {
const closeButton = event.target.closest('.quote-close');
if (closeButton) {
const quoteElement = event.target.closest('.editor-quote');
if (quoteElement) {
quoteElement.remove();
quoteData.value = null;
handleInput({ target: editorRef.value });
event.preventDefault();
event.stopPropagation();
}
}
};
2025-06-05 08:21:39 +00:00
</script>
<template>
<!--
编辑器容器组件
使用el-container布局组件创建垂直布局的编辑器
包含工具栏和编辑区域两个主要部分
-->
<section class="el-container editor">
<section class="el-container is-vertical">
<!--
工具栏区域
使用el-header组件作为工具栏容器
包含各种编辑工具按钮
-->
<header class="el-header toolbar bdr-t">
<div class="tools pr-30px">
2025-06-05 08:21:39 +00:00
<!--
表情选择器弹出框
使用n-popover组件创建悬浮的表情选择面板
通过trigger="click"设置点击触发
raw属性表示内容不会被包装在额外的div中
-->
<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>
<!--
表情选择器组件
通过on-select事件将选中的表情传递给onEmoticonEvent处理函数
-->
<MeEditorEmoticon @on-select="onEmoticonEvent" />
</n-popover>
<!--
工具栏其他功能按钮
通过v-for循环渲染navs数组中定义的工具按钮
每个按钮包含图标和提示文本
通过v-show="nav.show"控制按钮的显示/隐藏
点击时执行nav.click定义的函数
-->
<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>
<n-button class="w-80px h-30px ml-auto" type="primary" @click="sendMessage">
<template #icon>
<n-icon>
<IosSend />
</n-icon>
</template>
发送
</n-button>
2025-06-05 08:21:39 +00:00
</div>
</header>
<!--
编辑器主体区域
使用el-main组件作为主要内容区域
-->
<main class="el-main height100">
<!--
可编辑区域
使用contenteditable="true"使div可编辑这是HTML5的属性
ref="editorRef"用于获取DOM引用
placeholder属性用于显示占位文本
绑定多个事件处理函数处理用户交互
-->
<div
ref="editorRef"
class="custom-editor"
contenteditable="true"
:placeholder="placeholder"
@input="handleInput"
@keydown="handleKeydown"
@paste="handlePaste"
@focus="handleFocus"
@blur="handleBlur"
></div>
<!--
@提及列表
当用户输入@时显示的用户列表
通过v-if="showMention"控制显示/隐藏
使用动态样式定位列表位置
-->
<div
v-if="showMention"
class="mention-list py-5px"
2025-06-05 08:21:39 +00:00
:style="{ top: mentionPosition.top + 'px', left: mentionPosition.left + 'px' }"
>
<ul class="max-h-140px w-163px overflow-auto hide-scrollbar">
<li
2025-06-05 08:21:39 +00:00
v-for="(member, index) in mentionList"
:key="member.user_id || member.id"
class="cursor-pointer px-14px h-42px"
:class="{ 'bg-#EEE9F9': index === selectedMentionIndex }"
@mousedown.prevent="handleMentionSelectByMouse(member)"
@mouseover="selectedMentionIndex = index"
2025-06-05 08:21:39 +00:00
>
<div class="flex items-center border-b-1px border-b-solid border-b-#F8F8F8 h-full">
<img class="w-26px h-26px rounded-50% mr-11px" :src="member.avatar" alt="">
<span>{{ member.nickname }}</span>
</div>
</li>
2025-06-05 08:21:39 +00:00
</ul>
</div>
</main>
</section>
</section>
<!--
隐藏的文件上传表单
使用display: none隐藏表单
包含两个文件输入框分别用于上传图片和其他文件
通过ref获取DOM引用
-->
<form enctype="multipart/form-data" style="display: none">
<input type="file" ref="fileImageRef" accept="image/*" @change="onUploadSendImg" />
2025-06-05 08:21:39 +00:00
<input type="file" ref="uploadFileRef" @change="onUploadFile" />
</form>
</template>
<style lang="less" scoped>
/**
* 编辑器组件样式
* 使用CSS变量定义可复用的颜色值
* --tip-bg-color: 提示背景色
*/
.editor {
--tip-bg-color: rgb(241 241 241 / 90%);
height: 100%;
/**
* 工具栏样式
* 固定高度的顶部工具栏
*/
.toolbar {
height: 38px;
display: flex;
/**
* 工具按钮容器
* 使用flex布局排列工具按钮
*/
.tools {
height: 40px;
2025-06-05 08:21:39 +00:00
flex: auto;
display: flex;
align-items: center;
2025-06-05 08:21:39 +00:00
/**
* 单个工具按钮样式
* 使用flex布局居中对齐内容
* 相对定位用于放置提示文本
*/
.item {
display: flex;
align-items: center;
justify-content: center;
width: 35px;
margin: 0 2px;
position: relative;
user-select: none;
2025-06-05 08:21:39 +00:00
/**
* 提示文本样式
* 默认隐藏悬停时显示
* 使用绝对定位在按钮下方显示
*/
.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;
2025-06-05 08:21:39 +00:00
user-select: none;
z-index: 999999999999;
2025-06-05 08:21:39 +00:00
}
/**
* 悬停效果
* 当鼠标悬停在按钮上时显示提示文本
*/
&:hover {
.tip-title {
display: block;
}
}
}
}
}
:deep(.editor-file) {
display: inline-block;
padding: 5px 10px;
margin: 5px 0;
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 4px;
color: #462AA0;
2025-06-05 08:21:39 +00:00
text-decoration: none;
position: relative;
padding-right: 60px;
2025-06-05 08:21:39 +00:00
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
2025-06-05 08:21:39 +00:00
&::after {
content: attr(data-size);
position: absolute;
right: 10px;
color: #757575;
font-size: 12px;
}
&:hover {
background-color: #e3f2fd;
}
}
2025-06-05 08:21:39 +00:00
:deep(.editor-emoji) {
display: inline-block;
width: 24px;
height: 24px;
vertical-align: middle;
2025-06-05 08:21:39 +00:00
margin: 0 2px;
}
:deep(.editor-quote) {
margin-bottom: 8px;
padding: 8px 12px;
background-color: var(--im-message-left-bg-color, #f5f5f5);
border-left: 3px solid var(--im-primary-color, #409eff);
border-radius: 4px;
font-size: 13px;
position: relative;
display: flex;
justify-content: space-between;
align-items: flex-start;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
2025-06-05 08:21:39 +00:00
/**
* 引用卡片悬停效果
* 改变背景色提供视觉反馈
*/
&:hover {
background-color: var(--im-message-left-bg-hover-color, #eaeaea);
}
/**
* 引用内容容器
* 使用flex: 1占据剩余空间
* 处理内容溢出
*/
.quote-content-wrapper {
flex: 1;
overflow: hidden;
}
/**
* 引用标题样式
* 使用主题色显示引用来源
*/
.quote-title {
color: var(--im-primary-color, #409eff);
margin-bottom: 4px;
font-weight: 500;
}
/**
* 引用内容样式
* 限制显示行数超出部分显示省略号
* 使用-webkit-line-clamp实现多行文本截断
*/
.quote-content {
color: var(--im-text-color, #333);
word-break: break-all;
white-space: normal;
2025-06-05 08:21:39 +00:00
overflow: hidden;
text-overflow: ellipsis;
2025-06-05 08:21:39 +00:00
display: -webkit-box;
-webkit-line-clamp: 2;
2025-06-05 08:21:39 +00:00
-webkit-box-orient: vertical;
}
/**
* 引用图片样式
* 限制图片大小添加圆角
*/
.quote-image img {
max-width: 100px;
max-height: 60px;
border-radius: 3px;
pointer-events: none;
2025-06-05 08:21:39 +00:00
}
/**
* 引用关闭按钮样式
* 圆形按钮悬停时改变背景色和文字颜色
*/
.quote-close {
width: 18px;
height: 18px;
line-height: 16px;
text-align: center;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.1);
color: #666;
cursor: pointer;
font-size: 16px;
margin-left: 8px;
user-select: none;
&:hover {
background-color: rgba(0, 0, 0, 0.2);
color: #333;
}
}
}
.custom-editor {
width: 100%;
height: 100%;
padding: 8px;
border: none;
outline: none;
resize: none;
font-size: 14px;
line-height: 1.5;
color: #333;
background: transparent;
overflow-y: auto;
&:empty:before {
content: attr(placeholder);
color: #999;
pointer-events: none;
}
2025-06-05 08:21:39 +00:00
/**
* 自定义滚动条样式 - 轨道
* 使用::-webkit-scrollbar伪元素自定义滚动条轨道
* 设置宽度和高度为3px背景色为透明
*/
&::-webkit-scrollbar {
width: 3px;
height: 3px;
background-color: unset;
}
/**
* 自定义滚动条样式 - 滑块
* 使用::-webkit-scrollbar-thumb伪元素自定义滚动条滑块
* 设置圆角和透明背景色
*/
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: transparent;
}
/**
* 滚动条悬停效果
* 当鼠标悬停在编辑器上时显示滚动条滑块
* 使用CSS变量适配不同主题的滚动条颜色
*/
&:hover {
&::-webkit-scrollbar-thumb {
background-color: var(--im-scrollbar-thumb);
}
}
}
/**
* 编辑器占位符样式
* 使用:empty::before伪元素在编辑器为空时显示占位文本
* content: attr(placeholder)获取placeholder属性的值作为显示内容
*/
.custom-editor:empty::before {
content: attr(placeholder);
color: #999;
pointer-events: none;
2025-06-05 08:21:39 +00:00
font-family: PingFang SC, Microsoft YaHei, 'Alibaba PuHuiTi 2.0 45' !important;
}
/**
* 编辑器聚焦样式
* 移除默认的聚焦轮廓
*/
.custom-editor:focus {
outline: none;
}
/**
* @提及样式
* @提及的用户名添加特殊样式
* 使用蓝色背景和文字颜色突出显示
*/
.mention {
color: #1890ff;
background-color: #e6f7ff;
padding: 2px 4px;
border-radius: 3px;
text-decoration: none;
cursor: pointer;
}
/**
* @提及悬停效果
* 当鼠标悬停在@提及上时改变背景色
*/
.mention:hover {
background-color: #bae7ff;
}
/**
* 图片样式
* 限制编辑器中插入的图片大小
* 添加圆角和鼠标指针样式
*/
2025-06-05 08:21:39 +00:00
/**
* 表情样式
* 设置表情图片的大小和对齐方式
*/
.editor-emoji {
width: 20px;
height: 20px;
vertical-align: middle;
margin: 0 2px;
}
/**
* @提及列表样式
* 定位和样式化@提及的下拉列表
* 使用绝对定位白色背景和阴影效果
*/
.mention-list {
position: absolute;
background-color: white;
border: 1px solid #eee;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
z-index: 1000;
}
2025-06-05 08:21:39 +00:00
.quote-card {
background: #f5f5f5;
border-left: 3px solid #1890ff;
padding: 8px 12px;
margin: 8px 0;
border-radius: 4px;
position: relative;
}
/**
* 引用内容样式
* 设置较小的字体大小
*/
.quote-content {
font-size: 12px;
}
/**
* 引用标题样式
* 使用粗体和主题蓝色
* 添加底部间距
*/
.quote-title {
font-weight: bold;
color: #1890ff;
margin-bottom: 4px;
}
/**
* 引用文本样式
* 使用灰色文本和适当的行高
*/
.quote-text {
color: #666;
line-height: 1.4;
}
/**
* 引用关闭按钮样式
* 绝对定位在右上角
* 移除默认按钮样式添加鼠标指针
*/
.quote-close {
position: absolute;
top: 4px;
right: 4px;
background: none;
border: none;
cursor: pointer;
color: #999;
font-size: 12px;
}
/**
* 编辑提示样式
* 使用橙色背景和边框创建警示效果
* 添加内边距圆角和适当的字体大小
* 使用flex布局对齐内容
*/
.edit-tip {
background: #fff7e6;
border: 1px solid #ffd591;
padding: 6px 12px;
margin-bottom: 8px;
border-radius: 4px;
font-size: 12px;
color: #d46b08;
display: flex;
align-items: center;
gap: 8px;
}
/**
* 编辑提示按钮样式
* 移除默认按钮样式添加鼠标指针
* 使用margin-left: auto将按钮推到右侧
*/
.edit-tip button {
background: none;
border: none;
cursor: pointer;
color: #d46b08;
margin-left: auto;
}
}
/**
* 暗色模式样式调整
* 使用HTML属性选择器检测暗色主题
* 重新定义暗色模式下的CSS变量
*/
html[theme-mode='dark'] {
.editor {
--tip-bg-color: #48484d;
}
}
/**
* 隐藏滚动条样式
* 保留滚动功能但隐藏滚动条的视觉显示
*/
.hide-scrollbar {
&::-webkit-scrollbar {
width: 0;
display: none;
}
scrollbar-width: none;
-ms-overflow-style: none;
}
2025-06-05 08:21:39 +00:00
</style>