style(office): 移除编辑器配置中的多余空行
This commit is contained in:
parent
1a85e9d13e
commit
fd9a5555dc
@ -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 <br> tag or other empty structures like <p><br></p>.
|
||||
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(/<br\s*\/?>/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 <br> 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. <img class="emoji" alt="[微笑]">
|
||||
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() === '<br>') {
|
||||
if (editorRef.value && editorRef.value.innerHTML.toLowerCase() === '<br>') {
|
||||
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
|
||||
|
@ -57,7 +57,6 @@ const config = {
|
||||
},
|
||||
documentType,
|
||||
editorConfig: {
|
||||
|
||||
mode: 'view',
|
||||
lang: 'zh-CN',
|
||||
user: {
|
||||
|
Loading…
Reference in New Issue
Block a user