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] =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E4=BC=98=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; - } + /** * @提及悬停效果