diff --git a/src/components/editor/CustomEditor.vue b/src/components/editor/CustomEditor.vue index 5dcd1ad..4b18196 100644 --- a/src/components/editor/CustomEditor.vue +++ b/src/components/editor/CustomEditor.vue @@ -101,49 +101,34 @@ const handleInput = (event) => { // 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 target = editorNode; const editorClone = editorNode.cloneNode(true); - // 优化:移除引用元素,只执行一次 - editorClone.querySelectorAll('.editor-quote').forEach(quote => quote.remove()); + editorClone.querySelectorAll('.editor-quote').forEach(quote => quote.remove()); - // 提取文本内容,包括表情的alt文本 - // 注意:原逻辑中 textContent += altText 可能导致重复,因为 editorClone.textContent 可能已包含表情图片的文本(如果浏览器这样处理) - // 一个更可靠的方法是遍历子节点,或者在移除表情图片后再获取textContent - let rawTextContent = editorClone.textContent || ''; + let rawTextContent = editorClone.textContent || ''; const emojiImages = editorClone.querySelectorAll('img.editor-emoji'); - // 暂时保留原提取方式,但标记为待优化 - // TODO: 优化 editorContent.value 的准确性,避免重复计算表情文本 - if (emojiImages.length > 0) { + if (emojiImages.length > 0) { emojiImages.forEach(emoji => { const altText = emoji.getAttribute('alt'); if (altText) { - // 这里的拼接逻辑可能不完全准确,取决于textContent如何处理img的alt - rawTextContent += altText; + rawTextContent += altText; } }); } editorContent.value = rawTextContent; - // const editorNode = target; // Already defined as editorNode - const currentText = editorNode.textContent.trim(); + const currentText = editorNode.textContent.trim(); const hasSpecialElements = editorNode.querySelector('img, .editor-file, .mention'); - // 优化:清空编辑器内容的逻辑 - // 如果编辑器内没有可见的文本内容,也没有图片、文件、提及等特殊元素,则尝试清空。 - 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 !== '') { + if (currentText === '' && !hasSpecialElements) { + if (editorNode.innerHTML !== '') { editorNode.innerHTML = ''; } } editorHtml.value = editorNode.innerHTML || ''; - // TODO: parseEditorContent, saveDraft, emit input_event 考虑使用防抖 (debounce) - const currentEditorItems = parseEditorContent().items; + const currentEditorItems = parseEditorContent().items; checkMention(target); saveDraft(); @@ -153,8 +138,7 @@ const handleInput = (event) => { data: currentEditorItems.reduce((result, item) => { if (item.type === 1) return result + item.content; if (item.type === 3) return result + '[图片]'; - // TODO: 为其他消息类型(如文件)添加文本表示 - return result; + return result; }, '') }); }; @@ -239,8 +223,7 @@ const insertMention = (member, clonedRange) => { 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 atIndex = (textNode.nodeType === Node.TEXT_NODE && offset > 0) ? textContent.lastIndexOf('@', offset - 1) : -1; const mentionSpan = document.createElement('span'); mentionSpan.className = 'mention'; @@ -248,28 +231,20 @@ const insertMention = (member, clonedRange) => { mentionSpan.textContent = `@${member.value || member.nickname} `; mentionSpan.contentEditable = 'false'; - // 如果找到了 '@' 符号,并且它在当前文本节点内 - if (atIndex !== -1 && textNode.nodeType === Node.TEXT_NODE) { + if (atIndex !== -1 && textNode.nodeType === Node.TEXT_NODE) { const parent = textNode.parentNode; if (!parent) return; - // 设置范围以选中从 '@' 到当前光标位置的文本 - range.setStart(textNode, atIndex); + range.setStart(textNode, atIndex); range.setEnd(textNode, offset); - range.deleteContents(); // 删除选中的文本 (即 '@' 和查询词) - - range.insertNode(mentionSpan); // 插入提及元素 - } else { - // 如果没有找到 '@' 或者不在当前文本节点,直接在光标处插入 - if (!range.collapsed) { - range.deleteContents(); // 如果有选中文本,先删除 - } + range.deleteContents(); + range.insertNode(mentionSpan); } else { + if (!range.collapsed) { + range.deleteContents(); } range.insertNode(mentionSpan); } - // 直接将光标设置在提及元素后面,不使用零宽空格 - // 这样可以避免需要按两次删除键才能删除提及元素的问题 - range.setStartAfter(mentionSpan); + range.setStartAfter(mentionSpan); range.collapse(true); selection.removeAllRanges(); @@ -305,33 +280,26 @@ const handlePaste = (event) => { image.src = tempUrl; image.onload = () => { - URL.revokeObjectURL(tempUrl); // 及时释放对象URL - const form = new FormData(); + URL.revokeObjectURL(tempUrl); 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); + 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--) { + 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); + // editorImages[j].setAttribute('data-remote-url', data.ori_url); break; } } - handleInput({ target: editorRef.value }); // 更新编辑器状态 - } else { + handleInput({ target: editorRef.value }); } else { window['$message'].error(message || '图片上传失败'); - // 可选:如果上传失败,移除临时图片或显示错误提示 - const editorImages = editorRef.value.querySelectorAll('img.editor-image'); + 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(); @@ -343,8 +311,7 @@ const handlePaste = (event) => { }).catch(error => { console.error('Upload image error:', error); window['$message'].error('图片上传过程中发生错误'); - // 清理临时图片 - const editorImages = editorRef.value.querySelectorAll('img.editor-image'); + 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(); @@ -358,14 +325,12 @@ const handlePaste = (event) => { URL.revokeObjectURL(tempUrl); window['$message'].error('无法加载粘贴的图片'); }; - return; // 处理完第一个图片就返回 - } + return; } } } } - // 如果没有粘贴图片,则处理文本 - if (!imagePasted) { + if (!imagePasted) { const text = clipboardData.getData('text/plain') || ''; if (text) { const selection = window.getSelection(); @@ -374,8 +339,7 @@ const handlePaste = (event) => { range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); - // 将光标移到插入文本的末尾 - range.setStartAfter(textNode); + range.setStartAfter(textNode); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); @@ -415,8 +379,7 @@ const handleKeydown = (event) => { const editor = editorRef.value; if (!editor) return; - // 提及列表相关操作 - if (showMention.value) { + if (showMention.value) { const mentionUl = document.querySelector('.mention-list ul'); let handled = false; switch (event.key) { @@ -466,57 +429,46 @@ const handleKeydown = (event) => { } } - // 删除提及元素 (@mention) - if (event.key === 'Backspace' || event.key === 'Delete') { + if (event.key === 'Backspace' || event.key === 'Delete') { const selection = window.getSelection(); if (!selection || !selection.rangeCount) return; const range = selection.getRangeAt(0); if (range.collapsed) { let nodeToCheck = null; - let positionRelativeToCheck = ''; // 'before' or 'after' - + let positionRelativeToCheck = ''; const container = range.startContainer; const offset = range.startOffset; if (event.key === 'Backspace') { - if (offset === 0) { // 光标在节点开头 - nodeToCheck = container.previousSibling; + if (offset === 0) { nodeToCheck = container.previousSibling; positionRelativeToCheck = 'before'; } else if (container.nodeType === Node.ELEMENT_NODE && offset > 0) { - // 光标在元素节点内,检查前一个子节点 - nodeToCheck = container.childNodes[offset - 1]; + nodeToCheck = container.childNodes[offset - 1]; positionRelativeToCheck = 'before'; } } else if (event.key === 'Delete') { - if (container.nodeType === Node.TEXT_NODE && offset === container.textContent.length) { // 光标在文本节点末尾 - nodeToCheck = container.nextSibling; + 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]; + nodeToCheck = container.childNodes[offset]; positionRelativeToCheck = 'after'; } } - // 确保 nodeToCheck 是一个元素节点并且是 mention - if (nodeToCheck && nodeToCheck.nodeType === Node.ELEMENT_NODE && nodeToCheck.classList.contains('mention')) { + if (nodeToCheck && nodeToCheck.nodeType === Node.ELEMENT_NODE && nodeToCheck.classList.contains('mention')) { event.preventDefault(); const parent = nodeToCheck.parentNode; parent.removeChild(nodeToCheck); - // 不再需要检查和删除零宽空格,因为我们已经不使用零宽空格了 - + handleInput({ target: editor }); return; } } - // 如果选区不折叠(即选中了内容),并且选区包含了mention,默认行为通常能正确处理,无需特殊干预 - // 但如果需要更精细的控制,例如确保整个mention被删除,则需要额外逻辑 - } + } - // 处理换行 (Ctrl/Meta/Shift + Enter) - if (event.key === 'Enter' && (event.ctrlKey || event.metaKey || event.shiftKey)) { + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey || event.shiftKey)) { event.preventDefault(); const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { @@ -533,8 +485,7 @@ const handleKeydown = (event) => { return; } - // 处理发送消息 (Enter) - if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey) { + if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey) { event.preventDefault(); const messageData = parseEditorContent(); const isEmptyMessage = messageData.items.length === 0 || @@ -572,17 +523,14 @@ const sendMessage = () => { let finalItems = []; if (parsedData && parsedData.items) { finalItems = parsedData.items.map(item => { - if (item.type === 1 && typeof item.content === 'string') { // 文本类型 - let content = cleanInvisibleChars(item.content); + 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; + if (item.type === 3 && !item.content) return false; if (item.type === 4 && !item.content) return false; return true; }); } @@ -607,14 +555,9 @@ const sendMessage = () => { 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 + delete messageToSend.quote; } - - // 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; @@ -627,16 +570,13 @@ const sendMessage = () => { 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 + emit('editor-event', emitCall('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)); } @@ -655,19 +595,17 @@ const parseEditorContent = () => { } const tempDiv = document.createElement('div'); - tempDiv.innerHTML = editorHtml.value; // Use editorHtml.value as the source of truth for parsing + tempDiv.innerHTML = editorHtml.value; 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 + quoteElement.remove(); } 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 }); } @@ -684,38 +622,36 @@ const parseEditorContent = () => { switch (node.tagName) { case 'BR': - currentTextBuffer += '\n'; // Represent
as newline in text content + currentTextBuffer += '\n'; 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. [微笑] + const isTextEmojiPlaceholder = node.classList.contains('emoji'); if (isTextEmojiPlaceholder && alt) { - currentTextBuffer += alt; // Treat as text + currentTextBuffer += alt; } else if (src) { items.push({ - type: 3, // Image + type: 3, 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 + currentTextBuffer += node.textContent || ''; } else if (node.classList.contains('editor-file')) { flushTextBufferIfNeeded(); const fileUrl = node.getAttribute('data-url'); @@ -723,7 +659,7 @@ const parseEditorContent = () => { const fileSize = node.getAttribute('data-size-raw') || node.getAttribute('data-size') || 0; if (fileUrl && fileName) { items.push({ - type: 4, // File + type: 4, content: fileUrl, name: fileName, size: parseInt(fileSize, 10), @@ -739,7 +675,7 @@ const parseEditorContent = () => { }; Array.from(tempDiv.childNodes).forEach(processNodeRecursively); - flushTextBufferIfNeeded(); // Final flush for any remaining text + flushTextBufferIfNeeded(); return { items: items.length > 0 ? items : [{ type: 1, content: '' }], @@ -757,33 +693,24 @@ const clearEditor = () => { 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 + + hideMentionList(); 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 + handleInput(); - // 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: 'clear_event', // Or stick to 'input_event' if that's the convention + event: 'clear_event', data: '' }); if (editorRef.value) { nextTick(() => { editorRef.value.focus(); - // Ensure focus后编辑器仍然是空的,以保证placeholder显示 - if (editorRef.value && editorRef.value.innerHTML.toLowerCase() === '
') { + if (editorRef.value && editorRef.value.innerHTML.toLowerCase() === '
') { editorRef.value.innerHTML = ''; } }); @@ -797,10 +724,10 @@ 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.className = 'editor-image'; + img.alt = '图片'; + img.style.maxWidth = '200px'; + img.style.maxHeight = '200px'; img.style.borderRadius = '4px'; img.style.objectFit = 'contain'; img.style.margin = '5px'; @@ -825,20 +752,18 @@ const insertImage = (fileOrSrc, isUploaded = false, uploadedUrl = '') => { editorRef.value.focus(); range = document.createRange(); range.selectNodeContents(editorRef.value); - range.collapse(false); // End of editor + range.collapse(false); } } else { editorRef.value.focus(); range = document.createRange(); range.selectNodeContents(editorRef.value); - range.collapse(false); // End of editor + range.collapse(false); } range.deleteContents(); range.insertNode(img); - - // Add a space after the image for better typing experience - const spaceNode = document.createTextNode('\u00A0'); // Non-breaking space + const spaceNode = document.createTextNode('\u00A0'); range.insertNode(spaceNode); range.setStartAfter(spaceNode); range.collapse(true); @@ -846,20 +771,20 @@ const insertImage = (fileOrSrc, isUploaded = false, uploadedUrl = '') => { selection.addRange(range); editorRef.value.focus(); - handleInput(); // Use the global handleInput without passing event args + handleInput(); }; - if (typeof fileOrSrc === 'string') { // It's a URL + if (typeof fileOrSrc === 'string') { 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 + setupAndInsert(fileOrSrc); }; tempImageForSize.src = fileOrSrc; - } else if (fileOrSrc instanceof File && fileOrSrc.type.startsWith('image/')) { // It's a File object + } else if (fileOrSrc instanceof File && fileOrSrc.type.startsWith('image/')) { const reader = new FileReader(); reader.onload = (e) => { const dataUrl = e.target.result; @@ -869,7 +794,7 @@ const insertImage = (fileOrSrc, isUploaded = false, uploadedUrl = '') => { }; tempImageForSize.onerror = () => { console.warn('Failed to load image from FileReader for size calculation.'); - setupAndInsert(dataUrl); // Insert even if size calculation fails + setupAndInsert(dataUrl); }; tempImageForSize.src = dataUrl; }; @@ -904,24 +829,18 @@ const onUploadSendImg = async (event) => { 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 = '' + insertImage(file, false); const formData = new FormData(); formData.append('file', file); - formData.append('source', 'fonchain-chat'); // Consider making 'source' configurable + formData.append('source', 'fonchain-chat'); 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; @@ -930,20 +849,14 @@ const onUploadSendImg = async (event) => { 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 + handleInput(); } } 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, @@ -953,7 +866,6 @@ const onUploadSendImg = async (event) => { } 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]; @@ -975,13 +887,12 @@ const onUploadSendImg = async (event) => { } } } - if (event.target) event.target.value = ''; // Reset file input + if (event.target) event.target.value = ''; }; async function onUploadFile(e) { if (!e.target || !e.target.files || e.target.files.length === 0) return; const file = e.target.files[0]; - // It's good practice to reset the input value immediately to allow re-selecting the same file e.target.value = null; const fileType = file.type; @@ -989,42 +900,16 @@ async function onUploadFile(e) { 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 { 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); - } - */ + } } @@ -1073,30 +958,29 @@ const insertTextEmoji = (emojiText) => { 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.deleteContents(); // Clear any selected text or prepare cursor position + range.deleteContents(); 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 + selection.removeAllRanges(); + selection.addRange(range); - handleInput(); // Update editor state + handleInput(); }; @@ -1114,35 +998,29 @@ const insertImageEmoji = (imgSrc, altText) => { if (!editor.contains(range.commonAncestorContainer)) { range = document.createRange(); range.selectNodeContents(editor); - range.collapse(false); // Move to the end + range.collapse(false); } } else { range = document.createRange(); range.selectNodeContents(editor); - range.collapse(false); // Move to the end + range.collapse(false); } - range.deleteContents(); // Clear any selected text or prepare cursor position + range.deleteContents(); 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'; + img.alt = altText || 'emoji'; + img.className = 'editor-emoji'; + img.setAttribute('data-role', 'emoji'); range.insertNode(img); - // Insert a space after the emoji for better typing experience - const spaceNode = document.createTextNode('\u00A0'); // Non-breaking space, or use ' ' + const spaceNode = document.createTextNode('\u00A0'); range.setStartAfter(img); range.collapse(true); range.insertNode(spaceNode); - // Move cursor after the space range.setStartAfter(spaceNode); range.collapse(true); @@ -1151,7 +1029,7 @@ const insertImageEmoji = (imgSrc, altText) => { selection.addRange(range); } - handleInput(); // Update editor state + handleInput(); }; @@ -1160,7 +1038,7 @@ const onSubscribeMention = async (data) => { const editorNode = editorRef.value; editorNode.focus(); - await nextTick(); // Ensure focus and DOM updates are processed + await nextTick(); let selection = window.getSelection(); let range; @@ -1168,29 +1046,27 @@ const onSubscribeMention = async (data) => { if (selection && selection.rangeCount > 0) { range = selection.getRangeAt(0); if (!editorNode.contains(range.commonAncestorContainer)) { - // If current selection is outside editor, move to the end of the editor + range = document.createRange(); range.selectNodeContents(editorNode); - range.collapse(false); // false to collapse to the end + range.collapse(false); } } 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 + insertMention(data, range); } 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 + const newSelection = window.getSelection(); if (newSelection){ newSelection.removeAllRanges(); newSelection.addRange(fallbackRange); @@ -1201,10 +1077,8 @@ const onSubscribeMention = async (data) => { } }; -// 在组件顶层作用域定义handleDeleteQuote函数 const handleDeleteQuote = function(e) { - // 如果不是删除键或退格键,直接返回 - if (e.key !== 'Backspace' && e.key !== 'Delete') return; + if (e.key !== 'Backspace' && e.key !== 'Delete') return; const selection = window.getSelection(); if (selection.rangeCount === 0) return; @@ -1216,36 +1090,29 @@ const handleDeleteQuote = function(e) { const quoteElement = editor.querySelector('.editor-quote'); if (!quoteElement) { - // 如果没有引用元素,移除事件监听器 - editor.removeEventListener('keydown', handleDeleteQuote); + editor.removeEventListener('keydown', handleDeleteQuote); return; } - // 获取引用元素在编辑器子节点中的索引 - const quoteIndex = Array.from(editor.childNodes).indexOf(quoteElement); + const quoteIndex = Array.from(editor.childNodes).indexOf(quoteElement); - // 检查是否在引用元素前按下退格键 - const isBeforeQuote = e.key === 'Backspace' && + const isBeforeQuote = e.key === 'Backspace' && range.collapsed && range.startContainer === editor && quoteIndex === range.startOffset; - // 检查是否在引用元素后按下删除键 - const isAfterQuote = e.key === 'Delete' && + const isAfterQuote = e.key === 'Delete' && range.collapsed && range.startContainer === editor && quoteIndex === range.startOffset - 1; if (isBeforeQuote || isAfterQuote) { - // 阻止默认行为 - e.preventDefault(); + e.preventDefault(); - // 移除引用元素 - quoteElement.remove(); + quoteElement.remove(); quoteData.value = null; - // 触发输入事件更新编辑器内容 - handleInput({ target: editor }); + handleInput({ target: editor }); } }; @@ -1254,10 +1121,7 @@ const onSubscribeQuote = (data) => { 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) { @@ -1267,7 +1131,6 @@ const onSubscribeQuote = (data) => { } } - // Create quote element safely const quoteElement = document.createElement('div'); quoteElement.className = 'editor-quote'; quoteElement.contentEditable = 'false'; @@ -1298,15 +1161,12 @@ const onSubscribeQuote = (data) => { closeButton.className = 'quote-close'; closeButton.textContent = '×'; quoteElement.appendChild(closeButton); - - // Insert quote at the beginning if (editor.firstChild) { editor.insertBefore(quoteElement, editor.firstChild); } else { editor.appendChild(quoteElement); } - // 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) { @@ -1325,11 +1185,10 @@ const onSubscribeQuote = (data) => { nodeToPlaceCursorAfter.remove(); } quoteData.value = null; - editor.removeEventListener('keydown', handleDeleteQuote); // Clean up listener - handleInput(); // Update editor state + editor.removeEventListener('keydown', handleDeleteQuote); handleInput(); 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); @@ -1342,43 +1201,35 @@ const onSubscribeQuote = (data) => { }; quoteElement.addEventListener('click', handleQuoteClick); - editor.addEventListener('keydown', handleDeleteQuote); // Add keydown listener for deletion + editor.addEventListener('keydown', handleDeleteQuote); - // Set timeout to allow DOM to update, then focus and set cursor - setTimeout(() => { + setTimeout(() => { editor.focus(); const newSelection = window.getSelection(); if (!newSelection) return; let cursorPlaced = false; - // Try to restore saved range if it's still valid - if (savedRange) { + if (savedRange) { try { - // Check if the container of the saved range is still part of the editor - if (editor.contains(savedRange.commonAncestorContainer) && savedRange.startContainer) { + 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 - } + } } if (!cursorPlaced) { const newRange = document.createRange(); - // Ensure nodeToPlaceCursorAfter is still valid and in the DOM - if (nodeToPlaceCursorAfter && nodeToPlaceCursorAfter.parentNode === editor) { + 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); + newRange.setStartAfter(quoteElement.nextSibling); } else if (quoteElement.parentNode === editor) { - // Fallback to after quote element itself if it's the last child - newRange.setStartAfter(quoteElement); + newRange.setStartAfter(quoteElement); } else { - // Ultimate fallback: end of editor - newRange.selectNodeContents(editor); + newRange.selectNodeContents(editor); newRange.collapse(false); } newRange.collapse(true); @@ -1386,10 +1237,7 @@ const onSubscribeQuote = (data) => { 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 -}; + editor.scrollTop = editor.scrollHeight; handleInput(); }, 0); }; const onSubscribeEdit = (data) => { editingMessage.value = data diff --git a/src/views/office/index.vue b/src/views/office/index.vue index 9edc0a5..aa5bfa3 100644 --- a/src/views/office/index.vue +++ b/src/views/office/index.vue @@ -57,7 +57,6 @@ const config = { }, documentType, editorConfig: { - mode: 'view', lang: 'zh-CN', user: {