From bab907a1e29d0b21c46c09acd6042985445ed1f1 Mon Sep 17 00:00:00 2001
From: Phoenix <64720302+Concur-max@users.noreply.github.com>
Date: Wed, 11 Jun 2025 15:07:20 +0800
Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9C=AA=E8=AF=BB?=
=?UTF-8?q?=E6=B6=88=E6=81=AF=E6=95=B0=E9=87=8F=E6=98=BE=E7=A4=BA=E9=87=8D?=
=?UTF-8?q?=E5=A4=8D=E9=97=AE=E9=A2=98=E5=B9=B6=E7=A7=BB=E9=99=A4=E8=B0=83?=
=?UTF-8?q?=E8=AF=95=E6=97=A5=E5=BF=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
修复TalkItem.vue中未读消息数量显示重复的问题
移除MultiSelectFooter.vue中无用的调试日志打印
---
src/components/user/ContactModal.vue | 2 --
src/views/message/inner/panel/MultiSelectFooter.vue | 1 -
2 files changed, 3 deletions(-)
diff --git a/src/components/user/ContactModal.vue b/src/components/user/ContactModal.vue
index b49ade0..578ce54 100644
--- a/src/components/user/ContactModal.vue
+++ b/src/components/user/ContactModal.vue
@@ -131,8 +131,6 @@ const onSubmit = () => {
talk_type: item.talk_type
}
})
- console.log('data', data);
- console.log('checkedFilter.value', checkedFilter.value);
emit('on-submit', data)
}
diff --git a/src/views/message/inner/panel/MultiSelectFooter.vue b/src/views/message/inner/panel/MultiSelectFooter.vue
index dd2081f..976832c 100644
--- a/src/views/message/inner/panel/MultiSelectFooter.vue
+++ b/src/views/message/inner/panel/MultiSelectFooter.vue
@@ -59,7 +59,6 @@ const onContactModal = (data: { receiver_id: number; talk_type: number }[]) => {
group_ids.push(o.receiver_id)
}
}
- console.log('user_ids',user_ids)
dialogueStore.ApiForwardRecord({
mode: forwardMode.value,
message_ids: msg_ids,
From 1a85e9d13e787d82712e02f79cfe39483779d66a Mon Sep 17 00:00:00 2001
From: Phoenix <64720302+Concur-max@users.noreply.github.com>
Date: Wed, 11 Jun 2025 16:54:54 +0800
Subject: [PATCH 2/2] =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E4=BC=98?=
=?UTF-8?q?=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/editor/CustomEditor.vue | 1680 +++++++++++++-----------
1 file changed, 902 insertions(+), 778 deletions(-)
diff --git a/src/components/editor/CustomEditor.vue b/src/components/editor/CustomEditor.vue
index 03619ff..5dcd1ad 100644
--- a/src/components/editor/CustomEditor.vue
+++ b/src/components/editor/CustomEditor.vue
@@ -96,61 +96,68 @@ const toolbarConfig = computed(() => {
const handleInput = (event) => {
- const target = event.target
-
-
- const editorClone = target.cloneNode(true)
- const quoteElements = editorClone.querySelectorAll('.editor-quote')
- quoteElements.forEach(quote => quote.remove())
- quoteElements.forEach(quote => quote.remove())
-
-
- const emojiImages = editorClone.querySelectorAll('img.editor-emoji')
- let textContent = editorClone.textContent || ''
-
-
+ 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 的准确性,避免重复计算表情文本
if (emojiImages.length > 0) {
emojiImages.forEach(emoji => {
- const altText = emoji.getAttribute('alt')
+ const altText = emoji.getAttribute('alt');
if (altText) {
- textContent += altText
+ // 这里的拼接逻辑可能不完全准确,取决于textContent如何处理img的alt
+ rawTextContent += altText;
}
- })
+ });
}
-
-
- editorContent.value = textContent
+ editorContent.value = rawTextContent;
-
-
- const editorNode = target;
- const currentNormalizedHtml = editorNode.innerHTML.trim().toLowerCase().replace(/\s+/g, '');
-
- const hasTextContent = editorNode.textContent.trim() !== '';
+ // const editorNode = target; // Already defined as editorNode
+ const currentText = editorNode.textContent.trim();
const hasSpecialElements = editorNode.querySelector('img, .editor-file, .mention');
- if (!hasTextContent && !hasSpecialElements) {
- if (currentNormalizedHtml !== '' && currentNormalizedHtml !== '
') {
- editorNode.innerHTML = '';
+ // 优化:清空编辑器内容的逻辑
+ // 如果编辑器内没有可见的文本内容,也没有图片、文件、提及等特殊元素,则尝试清空。
+ 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
tag or other empty structures like
.
+ if (editorNode.innerHTML !== '') {
+ editorNode.innerHTML = '';
}
}
-
- editorHtml.value = target.innerHTML || ''
- const currentEditor= parseEditorContent().items
+ editorHtml.value = editorNode.innerHTML || '';
- checkMention(target)
- saveDraft()
+ // TODO: parseEditorContent, saveDraft, emit input_event 考虑使用防抖 (debounce)
+ const currentEditorItems = parseEditorContent().items;
+
+ checkMention(target);
+ saveDraft();
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
-}, '')
- })
-}
+ data: currentEditorItems.reduce((result, item) => {
+ if (item.type === 1) return result + item.content;
+ if (item.type === 3) return result + '[图片]';
+ // TODO: 为其他消息类型(如文件)添加文本表示
+ return result;
+ }, '')
+ });
+};
const checkMention = (target) => {
@@ -223,131 +230,161 @@ const updateMentionPosition = (range) => {
const insertMention = (member, clonedRange) => {
console.log('插入mention', member);
const selection = window.getSelection();
- if (!clonedRange || !selection) return;
+ if (!clonedRange || !selection || !editorRef.value) return;
- const range = clonedRange;
+ const range = clonedRange;
+ const editor = editorRef.value;
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.textContent = `@${member.value || member.nickname} `;
mentionSpan.contentEditable = 'false';
+ // 如果找到了 '@' 符号,并且它在当前文本节点内
if (atIndex !== -1 && textNode.nodeType === Node.TEXT_NODE) {
const parent = textNode.parentNode;
- if (!parent) return;
+ if (!parent) return;
-
+ // 设置范围以选中从 '@' 到当前光标位置的文本
range.setStart(textNode, atIndex);
range.setEnd(textNode, offset);
- range.deleteContents();
+ range.deleteContents(); // 删除选中的文本 (即 '@' 和查询词)
-
- range.insertNode(mentionSpan);
+ range.insertNode(mentionSpan); // 插入提及元素
} else {
-
+ // 如果没有找到 '@' 或者不在当前文本节点,直接在光标处插入
if (!range.collapsed) {
- range.deleteContents();
+ 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);
- }
+ // 直接将光标设置在提及元素后面,不使用零宽空格
+ // 这样可以避免需要按两次删除键才能删除提及元素的问题
+ range.setStartAfter(mentionSpan);
+ range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
- editorRef.value?.focus();
+ editor.focus();
nextTick(() => {
- handleInput({ target: editorRef.value });
+ handleInput({ target: editor });
hideMentionList();
});
};
const handlePaste = (event) => {
- event.preventDefault()
-
-
- const items = event.clipboardData?.items
+ event.preventDefault();
+ if (!editorRef.value) return;
+
+ const clipboardData = event.clipboardData;
+ if (!clipboardData) return;
+
+ const items = clipboardData.items;
+ let imagePasted = false;
+
if (items) {
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
-
- const file = items[i].getAsFile()
+ const file = items[i].getAsFile();
if (file) {
+ imagePasted = true;
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
+ 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; // 处理完第一个图片就返回
}
}
}
}
-
-
- const text = event.clipboardData?.getData('text/plain') || ''
-
- if (text) {
-
- 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)
-
-
- handleInput({ target: editorRef.value })
+
+ // 如果没有粘贴图片,则处理文本
+ 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 });
+ }
}
}
-}
+};
const insertLineBreak = (range) => {
const editor = editorRef.value;
if (!editor) return;
@@ -375,40 +412,40 @@ const insertLineBreak = (range) => {
};
const handleKeydown = (event) => {
-
+ const editor = editorRef.value;
+ if (!editor) return;
+
+ // 提及列表相关操作
if (showMention.value) {
+ const mentionUl = document.querySelector('.mention-list ul');
+ let handled = false;
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
+ 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;
}
- })
- break
+ }
+ handled = true;
+ 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
+ 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;
if (itemBottom > listBottom) {
- mentionList.scrollTop = itemBottom - mentionList.clientHeight
+ mentionUl.scrollTop = itemBottom - mentionUl.clientHeight;
}
}
- })
- break
+ }
+ handled = true;
+ break;
case 'Enter':
case 'Tab':
- event.preventDefault();
const selectedMember = mentionList.value[selectedMentionIndex.value];
if (selectedMember) {
const selection = window.getSelection();
@@ -416,161 +453,74 @@ const handleKeydown = (event) => {
insertMention(selectedMember, selection.getRangeAt(0).cloneRange());
}
}
+ handled = true;
break;
case 'Escape':
- hideMentionList()
- break
+ hideMentionList();
+ handled = true;
+ break;
+ }
+ if (handled) {
+ event.preventDefault();
+ return;
}
- return
}
-
-
+
+ // 删除提及元素 (@mention)
if (event.key === 'Backspace' || event.key === 'Delete') {
- const selection = window.getSelection()
- if (!selection.rangeCount) return
-
- const range = selection.getRangeAt(0)
- const editor = editorRef.value
-
-
+ const selection = window.getSelection();
+ if (!selection || !selection.rangeCount) return;
+
+ const range = selection.getRangeAt(0);
if (range.collapsed) {
- let targetMention = null
-
-
- const container = range.startContainer
- const offset = range.startOffset
-
+ let nodeToCheck = null;
+ let positionRelativeToCheck = ''; // 'before' or 'after'
+
+ 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
- }
- }
+ if (offset === 0) { // 光标在节点开头
+ nodeToCheck = container.previousSibling;
+ positionRelativeToCheck = 'before';
+ } else if (container.nodeType === Node.ELEMENT_NODE && offset > 0) {
+ // 光标在元素节点内,检查前一个子节点
+ nodeToCheck = container.childNodes[offset - 1];
+ positionRelativeToCheck = 'before';
}
} 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 (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';
}
}
-
- if (targetMention) {
- event.preventDefault()
+ // 确保 nodeToCheck 是一个元素节点并且是 mention
+ if (nodeToCheck && nodeToCheck.nodeType === Node.ELEMENT_NODE && nodeToCheck.classList.contains('mention')) {
+ event.preventDefault();
+ const parent = nodeToCheck.parentNode;
+ parent.removeChild(nodeToCheck);
-
- targetMention.remove()
-
-
- handleInput({ target: editor })
- return
+ // 不再需要检查和删除零宽空格,因为我们已经不使用零宽空格了
+
+ handleInput({ target: editor });
+ return;
}
}
+ // 如果选区不折叠(即选中了内容),并且选区包含了mention,默认行为通常能正确处理,无需特殊干预
+ // 但如果需要更精细的控制,例如确保整个mention被删除,则需要额外逻辑
}
-
-
+
+ // 处理换行 (Ctrl/Meta/Shift + Enter)
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey || event.shiftKey)) {
event.preventDefault();
-
- const editor = editorRef.value;
- if (!editor) return;
-
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
- // 如果没有选区,尝试聚焦并创建选区
editor.focus();
- // 等待DOM更新
nextTick(() => {
const newSelection = window.getSelection();
if (newSelection && newSelection.rangeCount > 0) {
@@ -579,300 +529,358 @@ const handleKeydown = (event) => {
});
return;
}
-
insertLineBreak(selection.getRangeAt(0));
return;
}
-
-
-
-
+ // 处理发送消息 (Enter)
if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
- event.preventDefault();
-
- const editor = editorRef.value;
- if (!editor) return;
-
- const messageData = parseEditorContent();
+ event.preventDefault();
+ const messageData = parseEditorContent();
const isEmptyMessage = messageData.items.length === 0 ||
- (messageData.items.length === 1 &&
- messageData.items[0].type === 1 &&
- !messageData.items[0].content.trimEnd());
+ (messageData.items.length === 1 &&
+ messageData.items[0].type === 1 &&
+ !messageData.items[0].content.trimEnd());
if (isEmptyMessage) {
- if (editor.innerHTML !== '') {
- clearEditor();
+ if (editor.innerHTML.trim() !== '' && editor.innerHTML.trim() !== '
') {
+ clearEditor();
}
- return;
+ return;
}
+
const quoteElement = editor.querySelector('.editor-quote');
if (!quoteElement && quoteData.value) {
quoteData.value = null;
}
- sendMessage();
+ sendMessage();
}
-}
+};
const sendMessage = () => {
-
- const messageData = parseEditorContent()
-
-
- if (messageData.items.length === 0 ||
- (messageData.items.length === 1 &&
- messageData.items[0].type === 1 &&
- !messageData.items[0].content.trimEnd())) {
- return
+ 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(/
/gi, '\n').trim();
+ return { ...item, content };
+ }
+ 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;
+ });
}
-
-
- function cleanInvisibleChars(text) {
- return text.replace(/[\u200B-\u200D\uFEFF]/g, '');
-}
- messageData.items.forEach(item => {
-
- if (item.type === 1 && cleanInvisibleChars(item.content).trimEnd()) {
- const finalContent = cleanInvisibleChars(item.content).replace(/
/gi, '\n').trimEnd();
- if (!finalContent && !messageData.mentionUids.length && !messageData.quoteId) {
-
- return;
- }
- const data = {
- items: [{
- content: finalContent,
- 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,
- }
- 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) {
-
+
+ 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() !== '
') {
+ clearEditor();
}
- })
-
-
- clearEditor()
+ 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();
}
const parseEditorContent = () => {
- const items = []
- const mentionUids = []
-
-
- 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())
-
- let textContent = ''
-
-
- const processNode = (node) => {
+ 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) => {
if (node.nodeType === Node.TEXT_NODE) {
- textContent += node.textContent;
+ currentTextBuffer += node.textContent;
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
- if (node.tagName === 'BR') {
- textContent += '\n';
- } else 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;
+ switch (node.tagName) {
+ case 'BR':
+ currentTextBuffer += '\n'; // Represent
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.
+
+ 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;
}
- }
-
-
- 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
- } else {
-
- items.push({
- type: 3,
- content: src + (width && height ? `?width=${width}&height=${height}` : ''),
- isEmoji: true
- })
- }
- } else {
-
- items.push({
- type: 3,
- content: src + (width && height ? `?width=${width}&height=${height}` : ''),
- width: width,
- height: height
- })
- }
- }
-
-
- 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)
-
-
- if (textContent) {
- items.push({
- type: 1,
- content: textContent
- });
- }
-
-
+ };
+
+ Array.from(tempDiv.childNodes).forEach(processNodeRecursively);
+ flushTextBufferIfNeeded(); // Final flush for any remaining text
+
return {
items: items.length > 0 ? items : [{ type: 1, content: '' }],
- mentionUids,
- quoteId
- }
-}
+ mentionUids: Array.from(mentionUids),
+ quoteId: parsedQuoteId
+ };
+};
const clearEditor = () => {
-
- editorContent.value = ''
- editorHtml.value = ''
- quoteData.value = null
-
-
if (editorRef.value) {
- editorRef.value.innerHTML = ''
-
- nextTick(() => editorRef.value.focus())
+ editorRef.value.innerHTML = '';
}
-
-
- hideMentionList()
-
-
- saveDraft()
-
-
+ 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
emit('editor-event', {
- event: 'input_event',
+ event: 'clear_event', // Or stick to 'input_event' if that's the convention
data: ''
- })
-}
+ });
+
+ if (editorRef.value) {
+ nextTick(() => {
+ editorRef.value.focus();
+ // Ensure focus后编辑器仍然是空的,以保证placeholder显示
+ if (editorRef.value && editorRef.value.innerHTML.toLowerCase() === '
') {
+ editorRef.value.innerHTML = '';
+ }
+ });
+ }
+};
-const insertImage = (src, width, height) => {
- const selection = window.getSelection()
- if (!selection.rangeCount) return
-
- const range = selection.getRangeAt(0)
-
-
- 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)
-
- range.deleteContents()
- range.insertNode(img)
- range.setStartAfter(img)
- range.collapse(true)
- selection.removeAllRanges()
- selection.addRange(range)
-
- editorRef.value.focus()
- handleInput({ target: editorRef.value })
-}
+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.');
+ }
+};
const formatFileSize = (size) => {
@@ -887,50 +895,136 @@ const formatFileSize = (size) => {
}
}
-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 onUploadSendImg = async (event) => {
+ if (!event.target || !event.target.files) return;
+ const files = event.target.files;
- const res=await uploadImg(form)
- if(res.status===0){
- const data={
- height:0,
- width:0,
- size:10000,
- url:res.data.ori_url,
+ 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
+ }
}
- emit(
- 'editor-event',
- emitCall(
- 'image_event',
- data
- )
- )
- }
+
+ if (!replacedPreview) {
+ // If preview wasn't found/replaced, insert the uploaded image anew.
+ insertImage(res.data.ori_url, true, res.data.ori_url);
+ }
+
+ // 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) {
- const file = e.target.files[0]
- if (!file) return
-
-
- e.target.value = null
-
- if (file.type.indexOf('image/') === 0) {
-
- emit('editor-event', emitCall('image_event', file))
+ if (!e.target || !e.target.files || e.target.files.length === 0) return;
+ const file = e.target.files[0];
- return
- }
+ // It's good practice to reset the input value immediately to allow re-selecting the same file
+ e.target.value = null;
- if (file.type.indexOf('video/') === 0) {
-
- emit('editor-event', emitCall('video_event', file))
+ 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));
} else {
-
- emit('editor-event', emitCall('file_event', file))
+ 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);
+ }
+ */
}
}
@@ -968,98 +1062,142 @@ const onEmoticonEvent = (emoji) => {
const insertTextEmoji = (emojiText) => {
- const editor = editorRef.value
- if (!editor) return
+ if (!editorRef.value || typeof emojiText !== 'string') return;
- editor.focus()
- const selection = window.getSelection()
- if (!selection.rangeCount) return
+ const editor = editorRef.value;
+ editor.focus();
- const range = selection.getRangeAt(0)
- range.deleteContents()
+ const selection = window.getSelection();
+ let range;
- const textNode = document.createTextNode(emojiText)
- range.insertNode(textNode)
+ 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);
+ }
-
- range.setStartAfter(textNode)
- range.collapse(true)
- selection.removeAllRanges()
- selection.addRange(range)
+ range.deleteContents(); // Clear any selected text or prepare cursor position
-
- handleInput({ target: editor })
-}
+ 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
+};
const insertImageEmoji = (imgSrc, altText) => {
- const editor = editorRef.value
- if (!editor) return
+ if (!editorRef.value || !imgSrc) return;
- editor.focus()
- const selection = window.getSelection()
- if (!selection.rangeCount) return
+ const editor = editorRef.value;
+ editor.focus();
- const range = selection.getRangeAt(0)
- range.deleteContents()
+ const selection = window.getSelection();
+ let range;
- const img = document.createElement('img')
- img.src = imgSrc
- img.alt = altText
- img.className = 'editor-emoji'
-
-
+ 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
+ }
+
+ range.deleteContents(); // Clear any selected text or prepare cursor position
+
+ 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';
+
+ 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);
+ if (selection) {
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
- range.insertNode(img)
-
-
- range.setStartAfter(img)
- range.collapse(true)
- selection.removeAllRanges()
- selection.addRange(range)
-
-
- handleInput({ target: editor })
-}
+ handleInput(); // Update editor state
+};
const onSubscribeMention = async (data) => {
+ if (!editorRef.value || !data) return;
const editorNode = editorRef.value;
- if (!editorNode) return;
editorNode.focus();
- await nextTick();
+ await nextTick(); // Ensure focus and DOM updates are processed
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();
- }
+ let range;
if (selection && selection.rangeCount > 0) {
- insertMention(data, selection.getRangeAt(0).cloneRange());
+ 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
+ }
+ } 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);
+ } else {
+ console.error("Could not get window selection to insert mention.");
+ }
}
};
@@ -1112,148 +1250,146 @@ const handleDeleteQuote = function(e) {
};
const onSubscribeQuote = (data) => {
-
- quoteData.value = data
-
-
- const editor = editorRef.value
- if (!editor) return
-
-
- const existingQuotes = editor.querySelectorAll('.editor-quote')
- existingQuotes.forEach(quote => quote.remove())
-
-
- const selection = window.getSelection()
- const savedRange = selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null
- const hasContent = editor.textContent.trim().length > 0
-
-
- const quoteElement = document.createElement('div')
- quoteElement.className = 'editor-quote'
- quoteElement.contentEditable = 'false'
-
-
- quoteElement.innerHTML = `
-
-
${data.title}
- ${data.image ? `
` : ''}
- ${data.describe ? `
${data.describe}
` : ''}
-
- ×
- `
-
-
+ 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
if (editor.firstChild) {
- editor.insertBefore(quoteElement, editor.firstChild)
+ editor.insertBefore(quoteElement, editor.firstChild);
} else {
- editor.appendChild(quoteElement)
+ 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) {
-
- quoteElement.remove()
- quoteData.value = null
-
-
- editorContent.value = editor.textContent || ''
- editorHtml.value = editor.innerHTML || ''
-
-
- nextTick(() => editor.focus())
- } else {
-
- const selection = window.getSelection()
- const range = document.createRange()
- range.setStartAfter(quoteElement)
- range.collapse(true)
- selection.removeAllRanges()
- selection.addRange(range)
-
-
- editor.focus()
- }
- })
-
- // 使用顶层作用域定义的handleDeleteQuote函数
- editor.addEventListener('keydown', handleDeleteQuote);
-
-
-setTimeout(() => {
-
- if (!editor.childNodes.length || (editor.childNodes.length === 1 && editor.childNodes[0] === quoteElement)) {
-
- const textNode = document.createTextNode('\u200B');
- editor.appendChild(textNode);
- }
-
-
- const currentHtml = editor.innerHTML;
- editor.innerHTML = currentHtml;
-
-
- const newQuoteElement = editor.querySelector('.editor-quote');
-
- editor.focus();
- const newSelection = window.getSelection();
- const newRange = document.createRange();
-
-
- if (newQuoteElement) {
-
- let nextNode = newQuoteElement.nextSibling;
- if (nextNode && nextNode.nodeType === 3) {
- newRange.setStart(nextNode, 0);
- } else if (nextNode) {
- newRange.setStartBefore(nextNode);
- } else {
- newRange.setStartAfter(newQuoteElement);
- }
+
+ // 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;
} else {
-
- if (editor.firstChild) {
- newRange.setStartBefore(editor.firstChild);
- } else {
- newRange.setStart(editor, 0);
- }
+ editor.insertBefore(zeroWidthSpace, quoteElement.nextSibling);
+ nodeToPlaceCursorAfter = zeroWidthSpace;
}
-
- newRange.collapse(true);
- newSelection.removeAllRanges();
- newSelection.addRange(newRange);
-
-
- editor.scrollTop = editor.scrollHeight;
-
-
- handleInput({ target: editor });
-
-
+
+ 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();
+ } else {
+ // 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();
+ }
+ };
+
+ 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
setTimeout(() => {
editor.focus();
-
- 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);
+ 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
+ }
}
- }, 50);
-}, 200);
-}
+
+ 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
+};
const onSubscribeEdit = (data) => {
editingMessage.value = data
@@ -1917,19 +2053,7 @@ const handleEditorClick = (event) => {
outline: none;
}
- /**
- * @提及样式
- * 为@提及的用户名添加特殊样式
- * 使用蓝色背景和文字颜色突出显示
- */
- .mention {
- color: #1890ff;
- background-color: #e6f7ff;
- padding: 2px 4px;
- border-radius: 3px;
- text-decoration: none;
- cursor: pointer;
- }
+
/**
* @提及悬停效果