2025-06-05 08:21:39 +00:00
|
|
|
|
<script setup>
|
2025-06-11 06:47:13 +00:00
|
|
|
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick, markRaw, watch } from 'vue'
|
2025-06-05 08:21:39 +00:00
|
|
|
|
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'
|
2025-06-11 03:39:11 +00:00
|
|
|
|
import { useDialogueStore, useEditorDraftStore ,useUserStore} from '@/store'
|
2025-06-05 08:21:39 +00:00
|
|
|
|
import { uploadImg } from '@/api/upload'
|
|
|
|
|
import { defAvatar } from '@/constant/default'
|
|
|
|
|
import { getImageInfo } from '@/utils/functions'
|
|
|
|
|
import MeEditorEmoticon from './MeEditorEmoticon.vue'
|
2025-06-06 06:49:38 +00:00
|
|
|
|
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: () => []
|
2025-06-06 06:49:38 +00:00
|
|
|
|
},
|
|
|
|
|
placeholder: {
|
|
|
|
|
type: String,
|
|
|
|
|
default: 'Enter-发送消息 [Ctrl+Enter/Shift+Enter]-换行'
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits(['editor-event'])
|
2025-06-11 03:39:11 +00:00
|
|
|
|
const userStore = useUserStore()
|
2025-06-05 08:21:39 +00:00
|
|
|
|
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-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const indexName = computed(() => dialogueStore.index_name)
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:44:57 +00:00
|
|
|
|
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('')
|
2025-06-09 08:48:52 +00:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
console.log('props.members',props.members)
|
|
|
|
|
}, 1000)
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const editorContent = ref('')
|
|
|
|
|
const editorHtml = ref('')
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
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-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const handleInput = (event) => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const editorNode = (event && event.target) ? event.target : editorRef.value;
|
|
|
|
|
if (!editorNode) {
|
|
|
|
|
// console.warn('handleInput called without a valid editor node.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const target = editorNode; // Keep target for existing logic if it's deeply coupled, or refactor to use editorNode directly
|
|
|
|
|
|
|
|
|
|
const editorClone = editorNode.cloneNode(true);
|
|
|
|
|
// 优化:移除引用元素,只执行一次
|
|
|
|
|
editorClone.querySelectorAll('.editor-quote').forEach(quote => quote.remove());
|
|
|
|
|
|
|
|
|
|
// 提取文本内容,包括表情的alt文本
|
|
|
|
|
// 注意:原逻辑中 textContent += altText 可能导致重复,因为 editorClone.textContent 可能已包含表情图片的文本(如果浏览器这样处理)
|
|
|
|
|
// 一个更可靠的方法是遍历子节点,或者在移除表情图片后再获取textContent
|
|
|
|
|
let rawTextContent = editorClone.textContent || '';
|
|
|
|
|
const emojiImages = editorClone.querySelectorAll('img.editor-emoji');
|
|
|
|
|
// 暂时保留原提取方式,但标记为待优化
|
|
|
|
|
// TODO: 优化 editorContent.value 的准确性,避免重复计算表情文本
|
2025-06-09 03:37:39 +00:00
|
|
|
|
if (emojiImages.length > 0) {
|
|
|
|
|
emojiImages.forEach(emoji => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const altText = emoji.getAttribute('alt');
|
2025-06-09 03:37:39 +00:00
|
|
|
|
if (altText) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// 这里的拼接逻辑可能不完全准确,取决于textContent如何处理img的alt
|
|
|
|
|
rawTextContent += altText;
|
2025-06-09 03:37:39 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
});
|
2025-06-09 03:37:39 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
editorContent.value = rawTextContent;
|
2025-06-11 03:20:15 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// const editorNode = target; // Already defined as editorNode
|
|
|
|
|
const currentText = editorNode.textContent.trim();
|
2025-06-11 03:20:15 +00:00
|
|
|
|
const hasSpecialElements = editorNode.querySelector('img, .editor-file, .mention');
|
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// 优化:清空编辑器内容的逻辑
|
|
|
|
|
// 如果编辑器内没有可见的文本内容,也没有图片、文件、提及等特殊元素,则尝试清空。
|
|
|
|
|
if (currentText === '' && !hasSpecialElements) {
|
|
|
|
|
// If the editor is visually empty (no text, no special elements),
|
|
|
|
|
// ensure its innerHTML is cleared to allow the placeholder to show.
|
|
|
|
|
// This handles cases where the browser might leave a <br> tag or other empty structures like <p><br></p>.
|
|
|
|
|
if (editorNode.innerHTML !== '') {
|
|
|
|
|
editorNode.innerHTML = '';
|
2025-06-11 03:20:15 +00:00
|
|
|
|
}
|
2025-06-09 03:37:39 +00:00
|
|
|
|
}
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
editorHtml.value = editorNode.innerHTML || '';
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// TODO: parseEditorContent, saveDraft, emit input_event 考虑使用防抖 (debounce)
|
|
|
|
|
const currentEditorItems = parseEditorContent().items;
|
|
|
|
|
|
|
|
|
|
checkMention(target);
|
|
|
|
|
saveDraft();
|
2025-06-09 03:37:39 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
emit('editor-event', {
|
|
|
|
|
event: 'input_event',
|
2025-06-11 08:54:54 +00:00
|
|
|
|
data: currentEditorItems.reduce((result, item) => {
|
|
|
|
|
if (item.type === 1) return result + item.content;
|
|
|
|
|
if (item.type === 3) return result + '[图片]';
|
|
|
|
|
// TODO: 为其他消息类型(如文件)添加文本表示
|
|
|
|
|
return result;
|
|
|
|
|
}, '')
|
|
|
|
|
});
|
|
|
|
|
};
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +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-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const showMentionList = () => {
|
|
|
|
|
const query = currentMentionQuery.value.toLowerCase()
|
2025-06-09 05:57:15 +00:00
|
|
|
|
mentionList.value = props.members.filter(member => {
|
2025-06-11 03:39:11 +00:00
|
|
|
|
return member.value.toLowerCase().startsWith(query)&& member.id !== userStore.uid
|
2025-06-09 05:57:15 +00:00
|
|
|
|
})
|
2025-06-11 03:39:11 +00:00
|
|
|
|
console.log('userStore',userStore.uid)
|
2025-06-09 08:48:52 +00:00
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-10 01:45:10 +00:00
|
|
|
|
const handleMentionSelectByMouse = (member) => {
|
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
if (selection && selection.rangeCount > 0) {
|
|
|
|
|
insertMention(member, selection.getRangeAt(0).cloneRange());
|
|
|
|
|
} else {
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-10 01:45:10 +00:00
|
|
|
|
editorRef.value?.focus();
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
const newSelection = window.getSelection();
|
|
|
|
|
if (newSelection && newSelection.rangeCount > 0) {
|
|
|
|
|
insertMention(member, newSelection.getRangeAt(0).cloneRange());
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const hideMentionList = () => {
|
|
|
|
|
showMention.value = false
|
|
|
|
|
mentionList.value = []
|
|
|
|
|
currentMentionQuery.value = ''
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-10 01:45:10 +00:00
|
|
|
|
const insertMention = (member, clonedRange) => {
|
|
|
|
|
console.log('插入mention', member);
|
|
|
|
|
const selection = window.getSelection();
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (!clonedRange || !selection || !editorRef.value) return;
|
2025-06-10 01:45:10 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const range = clonedRange;
|
|
|
|
|
const editor = editorRef.value;
|
2025-06-10 01:45:10 +00:00
|
|
|
|
|
|
|
|
|
const textNode = range.startContainer;
|
|
|
|
|
const offset = range.startOffset;
|
|
|
|
|
const textContent = textNode.nodeType === Node.TEXT_NODE ? textNode.textContent || '' : '';
|
2025-06-11 08:54:54 +00:00
|
|
|
|
|
|
|
|
|
// 查找光标前最后一个 '@' 符号的位置
|
2025-06-10 01:45:10 +00:00
|
|
|
|
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));
|
2025-06-11 08:54:54 +00:00
|
|
|
|
mentionSpan.textContent = `@${member.value || member.nickname} `;
|
2025-06-10 01:45:10 +00:00
|
|
|
|
mentionSpan.contentEditable = 'false';
|
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// 如果找到了 '@' 符号,并且它在当前文本节点内
|
2025-06-10 01:45:10 +00:00
|
|
|
|
if (atIndex !== -1 && textNode.nodeType === Node.TEXT_NODE) {
|
|
|
|
|
const parent = textNode.parentNode;
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (!parent) return;
|
2025-06-10 01:45:10 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// 设置范围以选中从 '@' 到当前光标位置的文本
|
2025-06-10 03:03:24 +00:00
|
|
|
|
range.setStart(textNode, atIndex);
|
|
|
|
|
range.setEnd(textNode, offset);
|
2025-06-11 08:54:54 +00:00
|
|
|
|
range.deleteContents(); // 删除选中的文本 (即 '@' 和查询词)
|
2025-06-10 01:45:10 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
range.insertNode(mentionSpan); // 插入提及元素
|
2025-06-05 08:21:39 +00:00
|
|
|
|
} else {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// 如果没有找到 '@' 或者不在当前文本节点,直接在光标处插入
|
2025-06-10 01:45:10 +00:00
|
|
|
|
if (!range.collapsed) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
range.deleteContents(); // 如果有选中文本,先删除
|
2025-06-10 01:45:10 +00:00
|
|
|
|
}
|
|
|
|
|
range.insertNode(mentionSpan);
|
2025-06-10 03:03:24 +00:00
|
|
|
|
}
|
2025-06-10 01:45:10 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// 直接将光标设置在提及元素后面,不使用零宽空格
|
|
|
|
|
// 这样可以避免需要按两次删除键才能删除提及元素的问题
|
|
|
|
|
range.setStartAfter(mentionSpan);
|
|
|
|
|
range.collapse(true);
|
2025-06-10 01:45:10 +00:00
|
|
|
|
|
|
|
|
|
selection.removeAllRanges();
|
|
|
|
|
selection.addRange(range);
|
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
editor.focus();
|
2025-06-10 01:45:10 +00:00
|
|
|
|
|
|
|
|
|
nextTick(() => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
handleInput({ target: editor });
|
2025-06-10 01:45:10 +00:00
|
|
|
|
hideMentionList();
|
|
|
|
|
});
|
|
|
|
|
};
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const handlePaste = (event) => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (!editorRef.value) return;
|
|
|
|
|
|
|
|
|
|
const clipboardData = event.clipboardData;
|
|
|
|
|
if (!clipboardData) return;
|
|
|
|
|
|
|
|
|
|
const items = clipboardData.items;
|
|
|
|
|
let imagePasted = false;
|
|
|
|
|
|
2025-06-06 02:44:17 +00:00
|
|
|
|
if (items) {
|
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
|
|
|
if (items[i].type.indexOf('image') !== -1) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const file = items[i].getAsFile();
|
2025-06-06 02:44:17 +00:00
|
|
|
|
if (file) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
imagePasted = true;
|
2025-06-06 08:57:02 +00:00
|
|
|
|
const tempUrl = URL.createObjectURL(file);
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const image = new Image();
|
|
|
|
|
image.src = tempUrl;
|
|
|
|
|
|
|
|
|
|
image.onload = () => {
|
|
|
|
|
URL.revokeObjectURL(tempUrl); // 及时释放对象URL
|
|
|
|
|
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 && data && data.ori_url) {
|
|
|
|
|
// 查找编辑器中对应的临时图片并替换其src
|
|
|
|
|
const editorImages = editorRef.value.querySelectorAll('img.editor-image');
|
|
|
|
|
// 从后向前查找,因为粘贴的图片通常是最后一个
|
|
|
|
|
for (let j = editorImages.length - 1; j >= 0; j--) {
|
|
|
|
|
if (editorImages[j].src === tempUrl) {
|
|
|
|
|
editorImages[j].src = data.ori_url;
|
|
|
|
|
// 可选:更新图片的data属性,如果需要的话
|
|
|
|
|
// editorImages[j].setAttribute('data-remote-url', data.ori_url);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
handleInput({ target: editorRef.value }); // 更新编辑器状态
|
|
|
|
|
} else {
|
|
|
|
|
window['$message'].error(message || '图片上传失败');
|
|
|
|
|
// 可选:如果上传失败,移除临时图片或显示错误提示
|
|
|
|
|
const editorImages = editorRef.value.querySelectorAll('img.editor-image');
|
|
|
|
|
for (let j = editorImages.length - 1; j >= 0; j--) {
|
|
|
|
|
if (editorImages[j].src === tempUrl) {
|
|
|
|
|
editorImages[j].remove();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
handleInput({ target: editorRef.value });
|
|
|
|
|
}
|
|
|
|
|
}).catch(error => {
|
|
|
|
|
console.error('Upload image error:', error);
|
|
|
|
|
window['$message'].error('图片上传过程中发生错误');
|
|
|
|
|
// 清理临时图片
|
|
|
|
|
const editorImages = editorRef.value.querySelectorAll('img.editor-image');
|
|
|
|
|
for (let j = editorImages.length - 1; j >= 0; j--) {
|
|
|
|
|
if (editorImages[j].src === tempUrl) {
|
|
|
|
|
editorImages[j].remove();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
handleInput({ target: editorRef.value });
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
image.onerror = () => {
|
|
|
|
|
URL.revokeObjectURL(tempUrl);
|
|
|
|
|
window['$message'].error('无法加载粘贴的图片');
|
|
|
|
|
};
|
|
|
|
|
return; // 处理完第一个图片就返回
|
2025-06-06 02:44:17 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
|
|
|
|
|
// 如果没有粘贴图片,则处理文本
|
|
|
|
|
if (!imagePasted) {
|
|
|
|
|
const text = clipboardData.getData('text/plain') || '';
|
|
|
|
|
if (text) {
|
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
if (selection && selection.rangeCount > 0) {
|
|
|
|
|
const range = selection.getRangeAt(0);
|
|
|
|
|
range.deleteContents();
|
|
|
|
|
const textNode = document.createTextNode(text);
|
|
|
|
|
range.insertNode(textNode);
|
|
|
|
|
// 将光标移到插入文本的末尾
|
|
|
|
|
range.setStartAfter(textNode);
|
|
|
|
|
range.collapse(true);
|
|
|
|
|
selection.removeAllRanges();
|
|
|
|
|
selection.addRange(range);
|
|
|
|
|
|
|
|
|
|
handleInput({ target: editorRef.value });
|
|
|
|
|
}
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
};
|
2025-06-11 03:20:15 +00:00
|
|
|
|
const insertLineBreak = (range) => {
|
|
|
|
|
const editor = editorRef.value;
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
|
|
|
|
const br = document.createElement('br');
|
2025-06-11 03:39:11 +00:00
|
|
|
|
range.deleteContents();
|
2025-06-11 03:20:15 +00:00
|
|
|
|
range.insertNode(br);
|
|
|
|
|
|
2025-06-11 03:39:11 +00:00
|
|
|
|
const nbsp = document.createTextNode('\u200B');
|
2025-06-11 03:20:15 +00:00
|
|
|
|
range.setStartAfter(br);
|
|
|
|
|
range.insertNode(nbsp);
|
|
|
|
|
range.setStartAfter(nbsp);
|
|
|
|
|
range.collapse(true);
|
|
|
|
|
|
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
if (selection) {
|
|
|
|
|
selection.removeAllRanges();
|
|
|
|
|
selection.addRange(range);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
editor.focus();
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
handleInput({ target: editor });
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const handleKeydown = (event) => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const editor = editorRef.value;
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
|
|
|
|
// 提及列表相关操作
|
2025-06-05 08:21:39 +00:00
|
|
|
|
if (showMention.value) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const mentionUl = document.querySelector('.mention-list ul');
|
|
|
|
|
let handled = false;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
switch (event.key) {
|
|
|
|
|
case 'ArrowUp':
|
2025-06-11 08:54:54 +00:00
|
|
|
|
selectedMentionIndex.value = Math.max(0, selectedMentionIndex.value - 1);
|
|
|
|
|
if (mentionUl) {
|
|
|
|
|
const selectedItem = mentionUl.children[selectedMentionIndex.value];
|
|
|
|
|
if (selectedItem && selectedItem.offsetTop < mentionUl.scrollTop) {
|
|
|
|
|
mentionUl.scrollTop = selectedItem.offsetTop;
|
2025-06-06 06:49:38 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
}
|
|
|
|
|
handled = true;
|
|
|
|
|
break;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
case 'ArrowDown':
|
2025-06-11 08:54:54 +00:00
|
|
|
|
selectedMentionIndex.value = Math.min(mentionList.value.length - 1, selectedMentionIndex.value + 1);
|
|
|
|
|
if (mentionUl) {
|
|
|
|
|
const selectedItem = mentionUl.children[selectedMentionIndex.value];
|
|
|
|
|
if (selectedItem) {
|
|
|
|
|
const itemBottom = selectedItem.offsetTop + selectedItem.offsetHeight;
|
|
|
|
|
const listBottom = mentionUl.scrollTop + mentionUl.clientHeight;
|
2025-06-06 06:49:38 +00:00
|
|
|
|
if (itemBottom > listBottom) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
mentionUl.scrollTop = itemBottom - mentionUl.clientHeight;
|
2025-06-06 06:49:38 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
}
|
|
|
|
|
handled = true;
|
|
|
|
|
break;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
case 'Enter':
|
|
|
|
|
case 'Tab':
|
2025-06-10 01:45:10 +00:00
|
|
|
|
const selectedMember = mentionList.value[selectedMentionIndex.value];
|
2025-06-09 03:37:39 +00:00
|
|
|
|
if (selectedMember) {
|
2025-06-10 01:45:10 +00:00
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
if (selection && selection.rangeCount > 0) {
|
|
|
|
|
insertMention(selectedMember, selection.getRangeAt(0).cloneRange());
|
|
|
|
|
}
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
handled = true;
|
2025-06-10 01:45:10 +00:00
|
|
|
|
break;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
case 'Escape':
|
2025-06-11 08:54:54 +00:00
|
|
|
|
hideMentionList();
|
|
|
|
|
handled = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (handled) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
return;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
|
|
|
|
|
// 删除提及元素 (@mention)
|
2025-06-09 05:57:15 +00:00
|
|
|
|
if (event.key === 'Backspace' || event.key === 'Delete') {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
if (!selection || !selection.rangeCount) return;
|
|
|
|
|
|
|
|
|
|
const range = selection.getRangeAt(0);
|
2025-06-09 05:57:15 +00:00
|
|
|
|
if (range.collapsed) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
let nodeToCheck = null;
|
|
|
|
|
let positionRelativeToCheck = ''; // 'before' or 'after'
|
|
|
|
|
|
|
|
|
|
const container = range.startContainer;
|
|
|
|
|
const offset = range.startOffset;
|
|
|
|
|
|
2025-06-09 05:57:15 +00:00
|
|
|
|
if (event.key === 'Backspace') {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (offset === 0) { // 光标在节点开头
|
|
|
|
|
nodeToCheck = container.previousSibling;
|
|
|
|
|
positionRelativeToCheck = 'before';
|
|
|
|
|
} else if (container.nodeType === Node.ELEMENT_NODE && offset > 0) {
|
|
|
|
|
// 光标在元素节点内,检查前一个子节点
|
|
|
|
|
nodeToCheck = container.childNodes[offset - 1];
|
|
|
|
|
positionRelativeToCheck = 'before';
|
2025-06-09 05:57:15 +00:00
|
|
|
|
}
|
|
|
|
|
} else if (event.key === 'Delete') {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (container.nodeType === Node.TEXT_NODE && offset === container.textContent.length) { // 光标在文本节点末尾
|
|
|
|
|
nodeToCheck = container.nextSibling;
|
|
|
|
|
positionRelativeToCheck = 'after';
|
|
|
|
|
} else if (container.nodeType === Node.ELEMENT_NODE && offset < container.childNodes.length) {
|
|
|
|
|
// 光标在元素节点内,检查当前子节点(或下一个,取决于如何定义删除)
|
|
|
|
|
nodeToCheck = container.childNodes[offset];
|
|
|
|
|
positionRelativeToCheck = 'after';
|
2025-06-09 05:57:15 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// 确保 nodeToCheck 是一个元素节点并且是 mention
|
|
|
|
|
if (nodeToCheck && nodeToCheck.nodeType === Node.ELEMENT_NODE && nodeToCheck.classList.contains('mention')) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const parent = nodeToCheck.parentNode;
|
|
|
|
|
parent.removeChild(nodeToCheck);
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// 不再需要检查和删除零宽空格,因为我们已经不使用零宽空格了
|
|
|
|
|
|
|
|
|
|
handleInput({ target: editor });
|
|
|
|
|
return;
|
2025-06-09 05:57:15 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// 如果选区不折叠(即选中了内容),并且选区包含了mention,默认行为通常能正确处理,无需特殊干预
|
|
|
|
|
// 但如果需要更精细的控制,例如确保整个mention被删除,则需要额外逻辑
|
2025-06-09 05:57:15 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
|
|
|
|
|
// 处理换行 (Ctrl/Meta/Shift + Enter)
|
2025-06-06 06:49:38 +00:00
|
|
|
|
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey || event.shiftKey)) {
|
2025-06-11 03:20:15 +00:00
|
|
|
|
event.preventDefault();
|
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
if (!selection || selection.rangeCount === 0) {
|
|
|
|
|
editor.focus();
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
const newSelection = window.getSelection();
|
|
|
|
|
if (newSelection && newSelection.rangeCount > 0) {
|
|
|
|
|
insertLineBreak(newSelection.getRangeAt(0));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return;
|
2025-06-06 06:49:38 +00:00
|
|
|
|
}
|
2025-06-11 03:20:15 +00:00
|
|
|
|
insertLineBreak(selection.getRangeAt(0));
|
|
|
|
|
return;
|
2025-06-06 06:49:38 +00:00
|
|
|
|
}
|
2025-06-11 03:20:15 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// 处理发送消息 (Enter)
|
2025-06-06 06:49:38 +00:00
|
|
|
|
if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
event.preventDefault();
|
|
|
|
|
const messageData = parseEditorContent();
|
2025-06-11 03:20:15 +00:00
|
|
|
|
const isEmptyMessage = messageData.items.length === 0 ||
|
2025-06-11 08:54:54 +00:00
|
|
|
|
(messageData.items.length === 1 &&
|
|
|
|
|
messageData.items[0].type === 1 &&
|
|
|
|
|
!messageData.items[0].content.trimEnd());
|
2025-06-11 03:20:15 +00:00
|
|
|
|
|
|
|
|
|
if (isEmptyMessage) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (editor.innerHTML.trim() !== '' && editor.innerHTML.trim() !== '<br>') {
|
|
|
|
|
clearEditor();
|
2025-06-11 03:20:15 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
return;
|
2025-06-11 03:20:15 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
|
2025-06-11 03:20:15 +00:00
|
|
|
|
const quoteElement = editor.querySelector('.editor-quote');
|
2025-06-09 03:37:39 +00:00
|
|
|
|
if (!quoteElement && quoteData.value) {
|
2025-06-11 03:20:15 +00:00
|
|
|
|
quoteData.value = null;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
2025-06-11 03:20:15 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
sendMessage();
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
};
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const sendMessage = () => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const editor = editorRef.value;
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
|
|
|
|
const parsedData = parseEditorContent();
|
|
|
|
|
|
|
|
|
|
const cleanInvisibleChars = (text) => {
|
|
|
|
|
return text ? String(text).replace(/[\u200B-\u200D\uFEFF]/g, '') : '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let finalItems = [];
|
|
|
|
|
if (parsedData && parsedData.items) {
|
|
|
|
|
finalItems = parsedData.items.map(item => {
|
|
|
|
|
if (item.type === 1 && typeof item.content === 'string') { // 文本类型
|
|
|
|
|
let content = cleanInvisibleChars(item.content);
|
|
|
|
|
content = content.replace(/<br\s*\/?>/gi, '\n').trim();
|
|
|
|
|
return { ...item, content };
|
2025-06-09 03:37:39 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
return item;
|
|
|
|
|
}).filter(item => {
|
|
|
|
|
if (item.type === 1 && !item.content && !(parsedData.mentionUids && parsedData.mentionUids.length > 0)) return false;
|
|
|
|
|
if (item.type === 3 && !item.content) return false; // 图片
|
|
|
|
|
if (item.type === 4 && !item.content) return false; // 文件
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasActualContent = finalItems.some(item => (item.type === 1 && item.content) || item.type === 3 || item.type === 4);
|
|
|
|
|
if (!hasActualContent && !(parsedData.mentionUids && parsedData.mentionUids.length > 0) && !parsedData.quoteId) {
|
|
|
|
|
if (editor.innerHTML.trim() !== '' && editor.innerHTML.trim() !== '<br>') {
|
|
|
|
|
clearEditor();
|
2025-06-09 03:37:39 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const messageToSend = {
|
|
|
|
|
items: finalItems.length > 0 ? finalItems : [{ type: 1, content: '' }],
|
|
|
|
|
mentionUids: parsedData.mentionUids || [],
|
|
|
|
|
mentions: (parsedData.mentionUids || []).map(uid => {
|
|
|
|
|
const member = mentionList.value.find(m => m.id === uid);
|
|
|
|
|
return { atid: uid, name: member ? member.nickname : '' };
|
|
|
|
|
}),
|
|
|
|
|
quoteId: parsedData.quoteId || null
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (messageToSend.quoteId && quoteData.value && quoteData.value.id === messageToSend.quoteId) {
|
|
|
|
|
messageToSend.quote = { ...quoteData.value };
|
|
|
|
|
} else if (messageToSend.quoteId) {
|
|
|
|
|
console.warn('sendMessage: Quote ID from parsed content exists, but no matching quoteData.value or ID mismatch.');
|
|
|
|
|
// Decide if sending without full quote object is acceptable or if quoteId should be removed
|
|
|
|
|
// For now, we keep quoteId but messageToSend.quote will not be populated with full details
|
|
|
|
|
} else {
|
|
|
|
|
delete messageToSend.quote; // No valid quoteId, so no quote object
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Determine event type based on content
|
|
|
|
|
const isSingleImageNoQuote = messageToSend.items.length === 1 && messageToSend.items[0].type === 3 && !messageToSend.quote;
|
|
|
|
|
const isSingleFileNoQuote = messageToSend.items.length === 1 && messageToSend.items[0].type === 4 && !messageToSend.quote;
|
|
|
|
|
|
|
|
|
|
if (isSingleImageNoQuote) {
|
|
|
|
|
const imgItem = messageToSend.items[0];
|
|
|
|
|
emit('editor-event', emitCall('image_event', {
|
|
|
|
|
url: imgItem.content,
|
|
|
|
|
width: imgItem.width || 0,
|
|
|
|
|
height: imgItem.height || 0,
|
|
|
|
|
size: imgItem.size || 0
|
|
|
|
|
}));
|
|
|
|
|
} else if (isSingleFileNoQuote) {
|
|
|
|
|
// Assuming a 'file_event' or similar for single files
|
|
|
|
|
// If not, this will also go to 'text_event'
|
|
|
|
|
const fileItem = messageToSend.items[0];
|
|
|
|
|
emit('editor-event', emitCall('file_event', { // Placeholder for actual file event
|
|
|
|
|
url: fileItem.content,
|
|
|
|
|
name: fileItem.name,
|
|
|
|
|
size: fileItem.size
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
|
|
|
|
// All other cases: text, mixed content, or items with quotes
|
|
|
|
|
emit('editor-event', emitCall('text_event', messageToSend));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearEditor();
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const parseEditorContent = () => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const items = [];
|
|
|
|
|
const mentionUids = new Set();
|
|
|
|
|
let parsedQuoteId = null;
|
|
|
|
|
|
|
|
|
|
const editorNode = editorRef.value;
|
|
|
|
|
if (!editorNode) {
|
|
|
|
|
return { items: [{ type: 1, content: '' }], mentionUids: [], quoteId: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tempDiv = document.createElement('div');
|
|
|
|
|
tempDiv.innerHTML = editorHtml.value; // Use editorHtml.value as the source of truth for parsing
|
|
|
|
|
|
|
|
|
|
const quoteElement = tempDiv.querySelector('.editor-quote');
|
|
|
|
|
if (quoteElement && quoteData.value && quoteData.value.id) {
|
|
|
|
|
parsedQuoteId = quoteData.value.id;
|
|
|
|
|
quoteElement.remove(); // Remove from tempDiv to avoid parsing its content
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let currentTextBuffer = '';
|
|
|
|
|
|
|
|
|
|
const flushTextBufferIfNeeded = () => {
|
|
|
|
|
// Only push non-empty text or if it's part of a larger structure (e.g. before an image)
|
|
|
|
|
// Actual trimming and empty checks will be done in sendMessage
|
|
|
|
|
if (currentTextBuffer) {
|
|
|
|
|
items.push({ type: 1, content: currentTextBuffer });
|
|
|
|
|
}
|
|
|
|
|
currentTextBuffer = '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const processNodeRecursively = (node) => {
|
2025-06-05 08:21:39 +00:00
|
|
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
currentTextBuffer += node.textContent;
|
2025-06-11 03:20:15 +00:00
|
|
|
|
return;
|
2025-06-09 03:37:39 +00:00
|
|
|
|
}
|
2025-06-11 03:20:15 +00:00
|
|
|
|
|
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
switch (node.tagName) {
|
|
|
|
|
case 'BR':
|
|
|
|
|
currentTextBuffer += '\n'; // Represent <br> as newline in text content
|
|
|
|
|
break;
|
|
|
|
|
case 'IMG':
|
|
|
|
|
flushTextBufferIfNeeded();
|
|
|
|
|
const src = node.getAttribute('src');
|
|
|
|
|
const alt = node.getAttribute('alt');
|
|
|
|
|
const isEmojiPic = node.classList.contains('editor-emoji');
|
|
|
|
|
const isTextEmojiPlaceholder = node.classList.contains('emoji'); // e.g. <img class="emoji" alt="[微笑]">
|
|
|
|
|
|
|
|
|
|
if (isTextEmojiPlaceholder && alt) {
|
|
|
|
|
currentTextBuffer += alt; // Treat as text
|
|
|
|
|
} else if (src) {
|
|
|
|
|
items.push({
|
|
|
|
|
type: 3, // Image
|
|
|
|
|
content: src,
|
|
|
|
|
isEmoji: isEmojiPic,
|
|
|
|
|
width: node.getAttribute('data-original-width') || node.width || null,
|
|
|
|
|
height: node.getAttribute('data-original-height') || node.height || null,
|
|
|
|
|
// size: node.getAttribute('data-size') || null, // If available
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
if (node.classList.contains('mention')) {
|
|
|
|
|
// Mentions are complex: they are part of text flow but also carry data.
|
|
|
|
|
// Here, we add their text to buffer and collect UID.
|
|
|
|
|
// The sendMessage function will construct the 'mentions' array.
|
|
|
|
|
const userId = node.getAttribute('data-user-id');
|
|
|
|
|
if (userId) {
|
|
|
|
|
mentionUids.add(Number(userId));
|
|
|
|
|
}
|
|
|
|
|
currentTextBuffer += node.textContent || ''; // Add mention text to buffer
|
|
|
|
|
} else if (node.classList.contains('editor-file')) {
|
|
|
|
|
flushTextBufferIfNeeded();
|
|
|
|
|
const fileUrl = node.getAttribute('data-url');
|
|
|
|
|
const fileName = node.getAttribute('data-name');
|
|
|
|
|
const fileSize = node.getAttribute('data-size-raw') || node.getAttribute('data-size') || 0;
|
|
|
|
|
if (fileUrl && fileName) {
|
|
|
|
|
items.push({
|
|
|
|
|
type: 4, // File
|
|
|
|
|
content: fileUrl,
|
|
|
|
|
name: fileName,
|
|
|
|
|
size: parseInt(fileSize, 10),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else if (node.childNodes && node.childNodes.length > 0) {
|
|
|
|
|
Array.from(node.childNodes).forEach(processNodeRecursively);
|
|
|
|
|
} else if (node.textContent) {
|
|
|
|
|
currentTextBuffer += node.textContent;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Array.from(tempDiv.childNodes).forEach(processNodeRecursively);
|
|
|
|
|
flushTextBufferIfNeeded(); // Final flush for any remaining text
|
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
return {
|
2025-06-05 08:21:39 +00:00
|
|
|
|
items: items.length > 0 ? items : [{ type: 1, content: '' }],
|
2025-06-11 08:54:54 +00:00
|
|
|
|
mentionUids: Array.from(mentionUids),
|
|
|
|
|
quoteId: parsedQuoteId
|
|
|
|
|
};
|
|
|
|
|
};
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const clearEditor = () => {
|
|
|
|
|
if (editorRef.value) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
editorRef.value.innerHTML = '';
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
editorContent.value = '';
|
|
|
|
|
editorHtml.value = '';
|
|
|
|
|
quoteData.value = null;
|
|
|
|
|
|
|
|
|
|
// Reset mention related states
|
|
|
|
|
hideMentionList(); // This already handles showMention, mentionList, currentMentionQuery
|
|
|
|
|
|
|
|
|
|
// Remove quote element from the DOM if it exists within the editor
|
|
|
|
|
const existingQuoteElement = editorRef.value ? editorRef.value.querySelector('.editor-quote') : null;
|
|
|
|
|
if (existingQuoteElement) {
|
|
|
|
|
existingQuoteElement.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// saveDraft(); // Consider if saveDraft should be called. Clearing usually means discarding.
|
|
|
|
|
// If draft should be cleared, it might be better to explicitly clear it:
|
|
|
|
|
// localStorage.removeItem('editorDraft'); // Example
|
|
|
|
|
|
|
|
|
|
// Trigger input event to update any listeners and ensure consistent state
|
|
|
|
|
handleInput(); // This will update editorHtml based on (now empty) editorRef.value.innerHTML
|
|
|
|
|
|
|
|
|
|
// Emit a specific clear event or ensure input_event with empty data is sufficient
|
2025-06-05 08:21:39 +00:00
|
|
|
|
emit('editor-event', {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
event: 'clear_event', // Or stick to 'input_event' if that's the convention
|
2025-06-05 08:21:39 +00:00
|
|
|
|
data: ''
|
2025-06-11 08:54:54 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (editorRef.value) {
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
editorRef.value.focus();
|
|
|
|
|
// Ensure focus后编辑器仍然是空的,以保证placeholder显示
|
|
|
|
|
if (editorRef.value && editorRef.value.innerHTML.toLowerCase() === '<br>') {
|
|
|
|
|
editorRef.value.innerHTML = '';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const insertImage = (fileOrSrc, isUploaded = false, uploadedUrl = '') => {
|
|
|
|
|
if (!editorRef.value) return;
|
|
|
|
|
|
|
|
|
|
const img = document.createElement('img');
|
|
|
|
|
img.className = 'editor-image'; // Keep existing class if it's styled
|
|
|
|
|
img.alt = '图片'; // Default alt text
|
|
|
|
|
img.style.maxWidth = '200px'; // Standardized max width
|
|
|
|
|
img.style.maxHeight = '200px'; // Standardized max height
|
|
|
|
|
img.style.borderRadius = '4px';
|
|
|
|
|
img.style.objectFit = 'contain';
|
|
|
|
|
img.style.margin = '5px';
|
|
|
|
|
|
|
|
|
|
const setupAndInsert = (imageUrl, naturalWidth, naturalHeight) => {
|
|
|
|
|
img.src = imageUrl;
|
|
|
|
|
if (naturalWidth) img.setAttribute('data-original-width', naturalWidth);
|
|
|
|
|
if (naturalHeight) img.setAttribute('data-original-height', naturalHeight);
|
|
|
|
|
if (isUploaded && uploadedUrl) {
|
|
|
|
|
img.setAttribute('data-uploaded-url', uploadedUrl);
|
|
|
|
|
img.setAttribute('data-status', 'uploaded');
|
|
|
|
|
} else {
|
|
|
|
|
img.setAttribute('data-status', 'local-preview');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
let range;
|
|
|
|
|
|
|
|
|
|
if (selection && selection.rangeCount > 0) {
|
|
|
|
|
range = selection.getRangeAt(0);
|
|
|
|
|
if (!editorRef.value.contains(range.commonAncestorContainer)) {
|
|
|
|
|
editorRef.value.focus();
|
|
|
|
|
range = document.createRange();
|
|
|
|
|
range.selectNodeContents(editorRef.value);
|
|
|
|
|
range.collapse(false); // End of editor
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
editorRef.value.focus();
|
|
|
|
|
range = document.createRange();
|
|
|
|
|
range.selectNodeContents(editorRef.value);
|
|
|
|
|
range.collapse(false); // End of editor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
range.deleteContents();
|
|
|
|
|
range.insertNode(img);
|
|
|
|
|
|
|
|
|
|
// Add a space after the image for better typing experience
|
|
|
|
|
const spaceNode = document.createTextNode('\u00A0'); // Non-breaking space
|
|
|
|
|
range.insertNode(spaceNode);
|
|
|
|
|
range.setStartAfter(spaceNode);
|
|
|
|
|
range.collapse(true);
|
|
|
|
|
selection.removeAllRanges();
|
|
|
|
|
selection.addRange(range);
|
|
|
|
|
|
|
|
|
|
editorRef.value.focus();
|
|
|
|
|
handleInput(); // Use the global handleInput without passing event args
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (typeof fileOrSrc === 'string') { // It's a URL
|
|
|
|
|
const tempImageForSize = new Image();
|
|
|
|
|
tempImageForSize.onload = () => {
|
|
|
|
|
setupAndInsert(fileOrSrc, tempImageForSize.naturalWidth, tempImageForSize.naturalHeight);
|
|
|
|
|
};
|
|
|
|
|
tempImageForSize.onerror = () => {
|
|
|
|
|
console.warn('Failed to load image from URL for size calculation:', fileOrSrc);
|
|
|
|
|
setupAndInsert(fileOrSrc); // Insert even if size calculation fails
|
|
|
|
|
};
|
|
|
|
|
tempImageForSize.src = fileOrSrc;
|
|
|
|
|
} else if (fileOrSrc instanceof File && fileOrSrc.type.startsWith('image/')) { // It's a File object
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
reader.onload = (e) => {
|
|
|
|
|
const dataUrl = e.target.result;
|
|
|
|
|
const tempImageForSize = new Image();
|
|
|
|
|
tempImageForSize.onload = () => {
|
|
|
|
|
setupAndInsert(dataUrl, tempImageForSize.naturalWidth, tempImageForSize.naturalHeight);
|
|
|
|
|
};
|
|
|
|
|
tempImageForSize.onerror = () => {
|
|
|
|
|
console.warn('Failed to load image from FileReader for size calculation.');
|
|
|
|
|
setupAndInsert(dataUrl); // Insert even if size calculation fails
|
|
|
|
|
};
|
|
|
|
|
tempImageForSize.src = dataUrl;
|
|
|
|
|
};
|
|
|
|
|
reader.onerror = (error) => {
|
|
|
|
|
console.error('FileReader error:', error);
|
|
|
|
|
};
|
|
|
|
|
reader.readAsDataURL(fileOrSrc);
|
|
|
|
|
} else {
|
|
|
|
|
console.warn('insertImage: Invalid file object or URL provided.');
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const onUploadSendImg = async (event) => {
|
|
|
|
|
if (!event.target || !event.target.files) return;
|
|
|
|
|
const files = event.target.files;
|
|
|
|
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
|
|
|
console.warn('Invalid file type for image upload:', file.type);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Optimistically insert a local preview using the already optimized insertImage function
|
|
|
|
|
insertImage(file, false); // isUploaded = false, uploadedUrl = ''
|
|
|
|
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
formData.append('source', 'fonchain-chat'); // Consider making 'source' configurable
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await uploadImg(formData);
|
|
|
|
|
if (res && res.status === 0 && res.data && res.data.ori_url) {
|
|
|
|
|
// Successfully uploaded. Update the preview image with the server URL.
|
|
|
|
|
// Find the corresponding preview image. This is a simplified approach.
|
|
|
|
|
// A more robust method would involve unique IDs for each preview.
|
|
|
|
|
const previewImages = editorRef.value.querySelectorAll('img[data-status="local-preview"][src^="data:image"]:not([data-uploaded-url])');
|
|
|
|
|
let replacedPreview = false;
|
|
|
|
|
if (previewImages.length > 0) {
|
|
|
|
|
// Try to find the correct preview. Assuming the last one is the most recent.
|
|
|
|
|
const lastPreviewImage = previewImages[previewImages.length - 1];
|
|
|
|
|
if (lastPreviewImage && lastPreviewImage.src.startsWith('data:image')) {
|
|
|
|
|
lastPreviewImage.src = res.data.ori_url;
|
|
|
|
|
lastPreviewImage.setAttribute('data-uploaded-url', res.data.ori_url);
|
|
|
|
|
lastPreviewImage.setAttribute('data-status', 'uploaded');
|
|
|
|
|
if (res.data.width) lastPreviewImage.setAttribute('data-original-width', res.data.width);
|
|
|
|
|
if (res.data.height) lastPreviewImage.setAttribute('data-original-height', res.data.height);
|
|
|
|
|
replacedPreview = true;
|
|
|
|
|
handleInput(); // Update editor state after modifying the image
|
|
|
|
|
}
|
2025-06-06 08:57:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (!replacedPreview) {
|
|
|
|
|
// If preview wasn't found/replaced, insert the uploaded image anew.
|
|
|
|
|
insertImage(res.data.ori_url, true, res.data.ori_url);
|
|
|
|
|
}
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// Emit an event that an image has been uploaded and inserted/updated
|
|
|
|
|
// This event is for the parent component, if it needs to react to the final image URL.
|
|
|
|
|
// The original emitCall('image_event', data) might be for sending the message immediately.
|
|
|
|
|
// Clarify if this function should *send* the image or just *insert* it for later sending.
|
|
|
|
|
// For now, let's assume the original intent was to emit an event that could lead to sending.
|
|
|
|
|
emit('editor-event', emitCall('image_event', {
|
|
|
|
|
url: res.data.ori_url,
|
|
|
|
|
width: res.data.width || 0,
|
|
|
|
|
height: res.data.height || 0,
|
|
|
|
|
size: file.size
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
console.error('Image upload failed or received invalid response:', res);
|
|
|
|
|
// Mark preview as failed
|
|
|
|
|
const previewImages = editorRef.value.querySelectorAll('img[data-status="local-preview"][src^="data:image"]:not([data-uploaded-url])');
|
|
|
|
|
if (previewImages.length > 0) {
|
|
|
|
|
const lastPreviewImage = previewImages[previewImages.length -1];
|
|
|
|
|
if(lastPreviewImage) {
|
|
|
|
|
lastPreviewImage.style.border = '2px dashed red';
|
|
|
|
|
lastPreviewImage.title = 'Upload failed';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error during image upload process:', error);
|
|
|
|
|
const previewImages = editorRef.value.querySelectorAll('img[data-status="local-preview"][src^="data:image"]:not([data-uploaded-url])');
|
|
|
|
|
if (previewImages.length > 0) {
|
|
|
|
|
const lastPreviewImage = previewImages[previewImages.length -1];
|
|
|
|
|
if(lastPreviewImage) {
|
|
|
|
|
lastPreviewImage.style.border = '2px dashed red';
|
|
|
|
|
lastPreviewImage.title = 'Upload error';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (event.target) event.target.value = ''; // Reset file input
|
|
|
|
|
};
|
|
|
|
|
async function onUploadFile(e) {
|
|
|
|
|
if (!e.target || !e.target.files || e.target.files.length === 0) return;
|
|
|
|
|
const file = e.target.files[0];
|
|
|
|
|
|
|
|
|
|
// It's good practice to reset the input value immediately to allow re-selecting the same file
|
|
|
|
|
e.target.value = null;
|
|
|
|
|
|
|
|
|
|
const fileType = file.type;
|
|
|
|
|
let eventName = '';
|
|
|
|
|
|
|
|
|
|
if (fileType.startsWith('image/')) {
|
|
|
|
|
eventName = 'image_event';
|
|
|
|
|
// For images, we might want to use onUploadSendImg to handle preview and upload directly
|
|
|
|
|
// Or, if this function is meant to be generic and just emit, then this is fine.
|
|
|
|
|
// However, onUploadSendImg seems more specialized for editor image insertion.
|
|
|
|
|
// Let's assume this onUploadFile is for a generic file picker that then emits.
|
|
|
|
|
// If direct insertion is needed, call appropriate insert function or onUploadSendImg.
|
|
|
|
|
// For consistency, if an image is chosen via this generic picker, and we want it in editor,
|
|
|
|
|
// we should probably call onUploadSendImg or insertImage.
|
|
|
|
|
// For now, sticking to emitting the raw file for parent to handle.
|
|
|
|
|
emit('editor-event', emitCall(eventName, file));
|
|
|
|
|
} else if (fileType.startsWith('video/')) {
|
|
|
|
|
eventName = 'video_event';
|
|
|
|
|
emit('editor-event', emitCall(eventName, file));
|
2025-06-05 08:21:39 +00:00
|
|
|
|
} else {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
eventName = 'file_event';
|
|
|
|
|
// If we want to insert a representation of the file into the editor before sending:
|
|
|
|
|
// 1. Upload the file
|
|
|
|
|
// 2. On success, insert a file node using a dedicated `insertFileNode` function.
|
|
|
|
|
// For now, just emitting the raw file.
|
|
|
|
|
emit('editor-event', emitCall(eventName, file));
|
|
|
|
|
// Example of how one might handle direct insertion after upload:
|
|
|
|
|
/*
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
formData.append('source', 'fonchain-chat');
|
|
|
|
|
try {
|
|
|
|
|
// Assuming a generic 'uploadActualFile' service exists
|
|
|
|
|
const res = await uploadActualFile(formData);
|
|
|
|
|
if (res && res.status === 0 && res.data && res.data.url) {
|
|
|
|
|
insertFileNode(res.data.url, file.name, file.size); // New function needed
|
|
|
|
|
} else {
|
|
|
|
|
console.error('File upload failed:', res);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error uploading file:', error);
|
|
|
|
|
}
|
|
|
|
|
*/
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const onEmoticonEvent = (emoji) => {
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
emoticonRef.value?.setShow(false)
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
switch (emoji.type) {
|
|
|
|
|
case 'text':
|
|
|
|
|
case 'emoji':
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
insertTextEmoji(emoji.value)
|
2025-06-09 03:37:39 +00:00
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
case 'image':
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
insertImageEmoji(emoji.img, emoji.value)
|
|
|
|
|
break
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
case 1:
|
2025-06-09 03:37:39 +00:00
|
|
|
|
emoji.img ? insertImageEmoji(emoji.img, emoji.value) : insertTextEmoji(emoji.value)
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
default:
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
emit('editor-event', {
|
|
|
|
|
event: 'emoticon_event',
|
|
|
|
|
data: emoji.value || emoji.id
|
|
|
|
|
})
|
|
|
|
|
break
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const insertTextEmoji = (emojiText) => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (!editorRef.value || typeof emojiText !== 'string') return;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const editor = editorRef.value;
|
|
|
|
|
editor.focus();
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
let range;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (selection && selection.rangeCount > 0) {
|
|
|
|
|
range = selection.getRangeAt(0);
|
|
|
|
|
if (!editor.contains(range.commonAncestorContainer)) {
|
|
|
|
|
// Range is outside the editor, reset to end of editor
|
|
|
|
|
range = document.createRange();
|
|
|
|
|
range.selectNodeContents(editor);
|
|
|
|
|
range.collapse(false);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// No selection, create range at the end of the editor
|
|
|
|
|
range = document.createRange();
|
|
|
|
|
range.selectNodeContents(editor);
|
|
|
|
|
range.collapse(false);
|
|
|
|
|
}
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
range.deleteContents(); // Clear any selected text or prepare cursor position
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const textNode = document.createTextNode(emojiText);
|
|
|
|
|
range.insertNode(textNode);
|
|
|
|
|
|
|
|
|
|
// Move cursor after the inserted text node
|
|
|
|
|
range.setStartAfter(textNode);
|
|
|
|
|
range.collapse(true);
|
|
|
|
|
selection.removeAllRanges(); // Deselect previous range
|
|
|
|
|
selection.addRange(range); // Apply new range
|
|
|
|
|
|
|
|
|
|
handleInput(); // Update editor state
|
|
|
|
|
};
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const insertImageEmoji = (imgSrc, altText) => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (!editorRef.value || !imgSrc) return;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const editor = editorRef.value;
|
|
|
|
|
editor.focus();
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
let range;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (selection && selection.rangeCount > 0) {
|
|
|
|
|
range = selection.getRangeAt(0);
|
|
|
|
|
if (!editor.contains(range.commonAncestorContainer)) {
|
|
|
|
|
range = document.createRange();
|
|
|
|
|
range.selectNodeContents(editor);
|
|
|
|
|
range.collapse(false); // Move to the end
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
range = document.createRange();
|
|
|
|
|
range.selectNodeContents(editor);
|
|
|
|
|
range.collapse(false); // Move to the end
|
|
|
|
|
}
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
range.deleteContents(); // Clear any selected text or prepare cursor position
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const img = document.createElement('img');
|
|
|
|
|
img.src = imgSrc;
|
|
|
|
|
img.alt = altText || 'emoji'; // Provide a default alt text
|
|
|
|
|
img.className = 'editor-emoji'; // Class for styling
|
|
|
|
|
img.setAttribute('data-role', 'emoji'); // For easier identification
|
|
|
|
|
// Consider setting a standard size for emoji images via CSS or attributes
|
|
|
|
|
// img.style.width = '20px';
|
|
|
|
|
// img.style.height = '20px';
|
|
|
|
|
// img.style.verticalAlign = 'middle';
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
range.insertNode(img);
|
|
|
|
|
|
|
|
|
|
// Insert a space after the emoji for better typing experience
|
|
|
|
|
const spaceNode = document.createTextNode('\u00A0'); // Non-breaking space, or use ' '
|
|
|
|
|
range.setStartAfter(img);
|
|
|
|
|
range.collapse(true);
|
|
|
|
|
range.insertNode(spaceNode);
|
|
|
|
|
|
|
|
|
|
// Move cursor after the space
|
|
|
|
|
range.setStartAfter(spaceNode);
|
|
|
|
|
range.collapse(true);
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (selection) {
|
|
|
|
|
selection.removeAllRanges();
|
|
|
|
|
selection.addRange(range);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleInput(); // Update editor state
|
|
|
|
|
};
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-10 01:45:10 +00:00
|
|
|
|
const onSubscribeMention = async (data) => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (!editorRef.value || !data) return;
|
2025-06-10 01:45:10 +00:00
|
|
|
|
const editorNode = editorRef.value;
|
|
|
|
|
|
|
|
|
|
editorNode.focus();
|
2025-06-11 08:54:54 +00:00
|
|
|
|
await nextTick(); // Ensure focus and DOM updates are processed
|
2025-06-10 01:45:10 +00:00
|
|
|
|
|
|
|
|
|
let selection = window.getSelection();
|
2025-06-11 08:54:54 +00:00
|
|
|
|
let range;
|
|
|
|
|
|
|
|
|
|
if (selection && selection.rangeCount > 0) {
|
|
|
|
|
range = selection.getRangeAt(0);
|
|
|
|
|
if (!editorNode.contains(range.commonAncestorContainer)) {
|
|
|
|
|
// If current selection is outside editor, move to the end of the editor
|
|
|
|
|
range = document.createRange();
|
|
|
|
|
range.selectNodeContents(editorNode);
|
|
|
|
|
range.collapse(false); // false to collapse to the end
|
2025-06-06 04:00:12 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
} else {
|
|
|
|
|
// No selection or invalid selection, create a new range at the end of the editor
|
|
|
|
|
range = document.createRange();
|
|
|
|
|
range.selectNodeContents(editorNode);
|
|
|
|
|
range.collapse(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure selection is updated with the correct range
|
|
|
|
|
if (selection) {
|
|
|
|
|
selection.removeAllRanges();
|
|
|
|
|
selection.addRange(range);
|
|
|
|
|
insertMention(data, range); // Pass the live range to insertMention
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback if selection is null for some reason (should be rare)
|
|
|
|
|
const fallbackRange = document.createRange();
|
|
|
|
|
fallbackRange.selectNodeContents(editorNode);
|
|
|
|
|
fallbackRange.collapse(false);
|
|
|
|
|
const newSelection = window.getSelection(); // Attempt to re-get selection
|
|
|
|
|
if (newSelection){
|
|
|
|
|
newSelection.removeAllRanges();
|
|
|
|
|
newSelection.addRange(fallbackRange);
|
|
|
|
|
insertMention(data, fallbackRange);
|
2025-06-10 01:45:10 +00:00
|
|
|
|
} else {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
console.error("Could not get window selection to insert mention.");
|
2025-06-10 01:45:10 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 06:47:13 +00:00
|
|
|
|
// 在组件顶层作用域定义handleDeleteQuote函数
|
|
|
|
|
const handleDeleteQuote = function(e) {
|
|
|
|
|
// 如果不是删除键或退格键,直接返回
|
|
|
|
|
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
|
|
|
|
|
|
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
if (selection.rangeCount === 0) return;
|
|
|
|
|
|
|
|
|
|
const range = selection.getRangeAt(0);
|
|
|
|
|
const editor = editorRef.value;
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
|
|
|
|
|
const quoteElement = editor.querySelector('.editor-quote');
|
|
|
|
|
|
|
|
|
|
if (!quoteElement) {
|
|
|
|
|
// 如果没有引用元素,移除事件监听器
|
|
|
|
|
editor.removeEventListener('keydown', handleDeleteQuote);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 获取引用元素在编辑器子节点中的索引
|
|
|
|
|
const quoteIndex = Array.from(editor.childNodes).indexOf(quoteElement);
|
|
|
|
|
|
|
|
|
|
// 检查是否在引用元素前按下退格键
|
|
|
|
|
const isBeforeQuote = e.key === 'Backspace' &&
|
|
|
|
|
range.collapsed &&
|
|
|
|
|
range.startContainer === editor &&
|
|
|
|
|
quoteIndex === range.startOffset;
|
|
|
|
|
|
|
|
|
|
// 检查是否在引用元素后按下删除键
|
|
|
|
|
const isAfterQuote = e.key === 'Delete' &&
|
|
|
|
|
range.collapsed &&
|
|
|
|
|
range.startContainer === editor &&
|
|
|
|
|
quoteIndex === range.startOffset - 1;
|
|
|
|
|
|
|
|
|
|
if (isBeforeQuote || isAfterQuote) {
|
|
|
|
|
// 阻止默认行为
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
// 移除引用元素
|
|
|
|
|
quoteElement.remove();
|
|
|
|
|
quoteData.value = null;
|
|
|
|
|
|
|
|
|
|
// 触发输入事件更新编辑器内容
|
|
|
|
|
handleInput({ target: editor });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const onSubscribeQuote = (data) => {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
if (!editorRef.value || !data) return;
|
|
|
|
|
quoteData.value = data;
|
|
|
|
|
const editor = editorRef.value;
|
|
|
|
|
|
|
|
|
|
// Remove existing quotes
|
|
|
|
|
editor.querySelectorAll('.editor-quote').forEach(quote => quote.remove());
|
|
|
|
|
|
|
|
|
|
// Save current selection if it's within the editor
|
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
let savedRange = null;
|
|
|
|
|
if (selection && selection.rangeCount > 0) {
|
|
|
|
|
const currentRange = selection.getRangeAt(0);
|
|
|
|
|
if (editor.contains(currentRange.commonAncestorContainer)) {
|
|
|
|
|
savedRange = currentRange.cloneRange();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create quote element safely
|
|
|
|
|
const quoteElement = document.createElement('div');
|
|
|
|
|
quoteElement.className = 'editor-quote';
|
|
|
|
|
quoteElement.contentEditable = 'false';
|
|
|
|
|
|
|
|
|
|
const wrapper = document.createElement('div');
|
|
|
|
|
wrapper.className = 'quote-content-wrapper';
|
|
|
|
|
const titleDiv = document.createElement('div');
|
|
|
|
|
titleDiv.className = 'quote-title';
|
|
|
|
|
titleDiv.textContent = data.title || ' ';
|
|
|
|
|
wrapper.appendChild(titleDiv);
|
|
|
|
|
if (data.image) {
|
|
|
|
|
const imageDiv = document.createElement('div');
|
|
|
|
|
imageDiv.className = 'quote-image';
|
|
|
|
|
const img = document.createElement('img');
|
|
|
|
|
img.src = data.image;
|
|
|
|
|
img.alt = '引用图片';
|
|
|
|
|
imageDiv.appendChild(img);
|
|
|
|
|
wrapper.appendChild(imageDiv);
|
|
|
|
|
}
|
|
|
|
|
if (data.describe) {
|
|
|
|
|
const contentDiv = document.createElement('div');
|
|
|
|
|
contentDiv.className = 'quote-content';
|
|
|
|
|
contentDiv.textContent = data.describe;
|
|
|
|
|
wrapper.appendChild(contentDiv);
|
|
|
|
|
}
|
|
|
|
|
quoteElement.appendChild(wrapper);
|
|
|
|
|
const closeButton = document.createElement('div');
|
|
|
|
|
closeButton.className = 'quote-close';
|
|
|
|
|
closeButton.textContent = '×';
|
|
|
|
|
quoteElement.appendChild(closeButton);
|
|
|
|
|
|
|
|
|
|
// Insert quote at the beginning
|
2025-06-05 08:21:39 +00:00
|
|
|
|
if (editor.firstChild) {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
editor.insertBefore(quoteElement, editor.firstChild);
|
2025-06-05 08:21:39 +00:00
|
|
|
|
} else {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
editor.appendChild(quoteElement);
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
|
|
|
|
|
// Ensure there's a node (like a zero-width space) after the quote for cursor placement
|
|
|
|
|
let nodeToPlaceCursorAfter = quoteElement;
|
|
|
|
|
const zeroWidthSpace = document.createTextNode('\u200B');
|
|
|
|
|
if (editor.lastChild === quoteElement || !quoteElement.nextSibling) {
|
|
|
|
|
editor.appendChild(zeroWidthSpace);
|
|
|
|
|
nodeToPlaceCursorAfter = zeroWidthSpace;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
} else {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
editor.insertBefore(zeroWidthSpace, quoteElement.nextSibling);
|
|
|
|
|
nodeToPlaceCursorAfter = zeroWidthSpace;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleQuoteClick = (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (e.target === closeButton || closeButton.contains(e.target)) {
|
|
|
|
|
quoteElement.remove();
|
|
|
|
|
if (nodeToPlaceCursorAfter.parentNode === editor && nodeToPlaceCursorAfter.nodeValue === '\u200B') {
|
|
|
|
|
nodeToPlaceCursorAfter.remove();
|
|
|
|
|
}
|
|
|
|
|
quoteData.value = null;
|
|
|
|
|
editor.removeEventListener('keydown', handleDeleteQuote); // Clean up listener
|
|
|
|
|
handleInput(); // Update editor state
|
|
|
|
|
editor.focus();
|
2025-06-05 08:21:39 +00:00
|
|
|
|
} else {
|
2025-06-11 08:54:54 +00:00
|
|
|
|
// Click on quote content, move cursor after the quote (or after the zeroWidthSpace)
|
|
|
|
|
const newRange = document.createRange();
|
|
|
|
|
newRange.setStartAfter(nodeToPlaceCursorAfter.parentNode === editor ? nodeToPlaceCursorAfter : quoteElement);
|
|
|
|
|
newRange.collapse(true);
|
|
|
|
|
if (selection) {
|
|
|
|
|
selection.removeAllRanges();
|
|
|
|
|
selection.addRange(newRange);
|
|
|
|
|
}
|
|
|
|
|
editor.focus();
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
quoteElement.addEventListener('click', handleQuoteClick);
|
|
|
|
|
editor.addEventListener('keydown', handleDeleteQuote); // Add keydown listener for deletion
|
|
|
|
|
|
|
|
|
|
// Set timeout to allow DOM to update, then focus and set cursor
|
2025-06-05 08:21:39 +00:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
editor.focus();
|
2025-06-11 08:54:54 +00:00
|
|
|
|
const newSelection = window.getSelection();
|
|
|
|
|
if (!newSelection) return;
|
|
|
|
|
|
|
|
|
|
let cursorPlaced = false;
|
|
|
|
|
// Try to restore saved range if it's still valid
|
|
|
|
|
if (savedRange) {
|
|
|
|
|
try {
|
|
|
|
|
// Check if the container of the saved range is still part of the editor
|
|
|
|
|
if (editor.contains(savedRange.commonAncestorContainer) && savedRange.startContainer) {
|
|
|
|
|
newSelection.removeAllRanges();
|
|
|
|
|
newSelection.addRange(savedRange);
|
|
|
|
|
cursorPlaced = true;
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// If restoring fails, fallback to placing cursor after quote
|
|
|
|
|
}
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
2025-06-11 08:54:54 +00:00
|
|
|
|
|
|
|
|
|
if (!cursorPlaced) {
|
|
|
|
|
const newRange = document.createRange();
|
|
|
|
|
// Ensure nodeToPlaceCursorAfter is still valid and in the DOM
|
|
|
|
|
if (nodeToPlaceCursorAfter && nodeToPlaceCursorAfter.parentNode === editor) {
|
|
|
|
|
newRange.setStartAfter(nodeToPlaceCursorAfter);
|
|
|
|
|
} else if (quoteElement.parentNode === editor && quoteElement.nextSibling) {
|
|
|
|
|
// Fallback to after quote element's direct next sibling if zeroWidthSpace was removed or invalid
|
|
|
|
|
newRange.setStartAfter(quoteElement.nextSibling);
|
|
|
|
|
} else if (quoteElement.parentNode === editor) {
|
|
|
|
|
// Fallback to after quote element itself if it's the last child
|
|
|
|
|
newRange.setStartAfter(quoteElement);
|
|
|
|
|
} else {
|
|
|
|
|
// Ultimate fallback: end of editor
|
|
|
|
|
newRange.selectNodeContents(editor);
|
|
|
|
|
newRange.collapse(false);
|
|
|
|
|
}
|
|
|
|
|
newRange.collapse(true);
|
|
|
|
|
newSelection.removeAllRanges();
|
|
|
|
|
newSelection.addRange(newRange);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
editor.scrollTop = editor.scrollHeight; // Scroll to bottom if needed
|
|
|
|
|
handleInput(); // Update editor state
|
|
|
|
|
}, 0); // A small delay like 0 or 50ms is usually enough
|
|
|
|
|
};
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
|
|
|
|
const onSubscribeEdit = (data) => {
|
|
|
|
|
editingMessage.value = data
|
|
|
|
|
clearEditor()
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
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-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const onSubscribeClear = () => {
|
|
|
|
|
clearEditor()
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const saveDraft = () => {
|
2025-06-09 03:37:39 +00:00
|
|
|
|
if (!indexName.value || !editorRef.value) return
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
|
|
|
|
|
2025-06-09 03:37: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
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
const quoteElements = tempDiv.querySelectorAll('.editor-quote')
|
|
|
|
|
quoteElements.forEach(quote => quote.remove())
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
const contentToSave = tempDiv.textContent || ''
|
|
|
|
|
const htmlToSave = tempDiv.innerHTML || ''
|
2025-06-10 07:03:29 +00:00
|
|
|
|
const currentEditor= parseEditorContent().items
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
const hasContent = contentToSave.trim().length > 0 ||
|
|
|
|
|
htmlToSave.includes('<img') ||
|
|
|
|
|
htmlToSave.includes('editor-file')
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-10 07:03:29 +00:00
|
|
|
|
if (currentEditor.length>0) {
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
editorDraftStore.items[indexName.value] = JSON.stringify({
|
2025-06-11 01:51:16 +00:00
|
|
|
|
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-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
delete editorDraftStore.items[indexName.value]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const loadDraft = () => {
|
|
|
|
|
if (!indexName.value) return
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
nextTick(() => {
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const currentQuoteData = quoteData.value
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
quoteData.value = null
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
if (!editorRef.value) return
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
editorRef.value.innerHTML = ''
|
|
|
|
|
editorContent.value = ''
|
|
|
|
|
editorHtml.value = ''
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
const draft = editorDraftStore.items[indexName.value]
|
2025-06-09 03:37:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
if (draft) {
|
|
|
|
|
try {
|
|
|
|
|
const draftData = JSON.parse(draft)
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
editorRef.value.innerHTML = draftData.html || ''
|
|
|
|
|
editorContent.value = draftData.content || ''
|
|
|
|
|
editorHtml.value = draftData.html || ''
|
2025-06-05 08:21:39 +00:00
|
|
|
|
} catch (error) {
|
2025-06-09 03:37:39 +00:00
|
|
|
|
console.warn('加载草稿失败,使用空内容', error)
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-09 03:37:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
if (currentQuoteData) {
|
|
|
|
|
onSubscribeQuote(currentQuoteData)
|
|
|
|
|
}
|
|
|
|
|
})
|
2025-06-05 08:21:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
watch(indexName, loadDraft, { immediate: true })
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
const handleDocumentClick = (event) => {
|
|
|
|
|
if (!editorRef.value?.contains(event.target)) {
|
|
|
|
|
hideMentionList()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
onMounted(() => {
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
const subscriptions = [
|
|
|
|
|
[EditorConst.Mention, onSubscribeMention],
|
|
|
|
|
[EditorConst.Quote, onSubscribeQuote],
|
|
|
|
|
[EditorConst.Edit, onSubscribeEdit],
|
|
|
|
|
[EditorConst.Clear, onSubscribeClear]
|
|
|
|
|
]
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
subscriptions.forEach(([event, handler]) => {
|
|
|
|
|
bus.subscribe(event, handler)
|
2025-06-05 08:21:39 +00:00
|
|
|
|
})
|
2025-06-09 03:37:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
editorRef.value?.addEventListener('click', handleEditorClick)
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
document.addEventListener('click', handleDocumentClick)
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
loadDraft()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 组件生命周期钩子 - 组件卸载前
|
2025-06-09 03:37:39 +00:00
|
|
|
|
* 清理所有事件订阅和监听器,防止内存泄漏
|
2025-06-05 08:21:39 +00:00
|
|
|
|
*/
|
|
|
|
|
onBeforeUnmount(() => {
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
const subscriptions = [
|
|
|
|
|
[EditorConst.Mention, onSubscribeMention],
|
|
|
|
|
[EditorConst.Quote, onSubscribeQuote],
|
|
|
|
|
[EditorConst.Edit, onSubscribeEdit],
|
|
|
|
|
[EditorConst.Clear, onSubscribeClear]
|
|
|
|
|
]
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
subscriptions.forEach(([event, handler]) => {
|
|
|
|
|
bus.unsubscribe(event, handler)
|
|
|
|
|
})
|
2025-06-06 05:43:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37:39 +00:00
|
|
|
|
editorRef.value?.removeEventListener('click', handleEditorClick)
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-09 03:37: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-11 01:51:16 +00:00
|
|
|
|
|
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
|
|
|
|
|
}
|
2025-06-06 05:43:39 +00:00
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-06 05:43:39 +00:00
|
|
|
|
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">
|
2025-06-06 06:49:38 +00:00
|
|
|
|
<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>
|
2025-06-10 01:45:10 +00:00
|
|
|
|
<n-button class="w-80px h-30px ml-auto" type="primary" @click="sendMessage">
|
2025-06-06 06:49:38 +00:00
|
|
|
|
<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"
|
2025-06-06 06:49:38 +00:00
|
|
|
|
class="mention-list py-5px"
|
2025-06-05 08:21:39 +00:00
|
|
|
|
:style="{ top: mentionPosition.top + 'px', left: mentionPosition.left + 'px' }"
|
|
|
|
|
>
|
2025-06-06 06:49:38 +00:00
|
|
|
|
<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"
|
2025-06-06 06:49:38 +00:00
|
|
|
|
:key="member.user_id || member.id"
|
|
|
|
|
class="cursor-pointer px-14px h-42px"
|
|
|
|
|
:class="{ 'bg-#EEE9F9': index === selectedMentionIndex }"
|
2025-06-10 01:45:10 +00:00
|
|
|
|
@mousedown.prevent="handleMentionSelectByMouse(member)"
|
2025-06-06 06:49:38 +00:00
|
|
|
|
@mouseover="selectedMentionIndex = index"
|
2025-06-05 08:21:39 +00:00
|
|
|
|
>
|
2025-06-06 06:49:38 +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">
|
2025-06-06 08:57:02 +00:00
|
|
|
|
<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 {
|
2025-06-06 06:49:38 +00:00
|
|
|
|
height: 40px;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
flex: auto;
|
|
|
|
|
display: flex;
|
2025-06-06 06:49:38 +00:00
|
|
|
|
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;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
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;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
white-space: pre;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
user-select: none;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
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;
|
2025-06-06 08:57:02 +00:00
|
|
|
|
color: #462AA0;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
text-decoration: none;
|
|
|
|
|
position: relative;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
padding-right: 60px;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
max-width: 100%;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
overflow: hidden;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
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-06 08:57:02 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
:deep(.editor-emoji) {
|
|
|
|
|
display: inline-block;
|
|
|
|
|
width: 24px;
|
|
|
|
|
height: 24px;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
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;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
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);
|
2025-06-11 01:51:16 +00:00
|
|
|
|
word-break: break-all;
|
|
|
|
|
white-space: normal;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
overflow: hidden;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
text-overflow: ellipsis;
|
2025-06-05 08:21:39 +00:00
|
|
|
|
display: -webkit-box;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
-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;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
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;
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-06 06:49:38 +00:00
|
|
|
|
&: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;
|
2025-06-11 01:51:16 +00:00
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 08:54:54 +00:00
|
|
|
|
|
2025-06-05 08:21:39 +00:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @提及悬停效果
|
|
|
|
|
* 当鼠标悬停在@提及上时改变背景色
|
|
|
|
|
*/
|
|
|
|
|
.mention:hover {
|
|
|
|
|
background-color: #bae7ff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 图片样式
|
|
|
|
|
* 限制编辑器中插入的图片大小
|
|
|
|
|
* 添加圆角和鼠标指针样式
|
|
|
|
|
*/
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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-11 01:51:16 +00:00
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-06 06:49:38 +00:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 隐藏滚动条样式
|
|
|
|
|
* 保留滚动功能但隐藏滚动条的视觉显示
|
|
|
|
|
*/
|
|
|
|
|
.hide-scrollbar {
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-06 06:49:38 +00:00
|
|
|
|
&::-webkit-scrollbar {
|
|
|
|
|
width: 0;
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-06 06:49:38 +00:00
|
|
|
|
scrollbar-width: none;
|
|
|
|
|
|
2025-06-11 01:51:16 +00:00
|
|
|
|
|
2025-06-06 06:49:38 +00:00
|
|
|
|
-ms-overflow-style: none;
|
|
|
|
|
}
|
2025-06-05 08:21:39 +00:00
|
|
|
|
</style>
|