style(office): 移除编辑器配置中的多余空行

This commit is contained in:
Phoenix 2025-06-12 10:05:07 +08:00
parent 1a85e9d13e
commit fd9a5555dc
2 changed files with 120 additions and 273 deletions

View File

@ -101,49 +101,34 @@ const handleInput = (event) => {
// console.warn('handleInput called without a valid editor node.'); // console.warn('handleInput called without a valid editor node.');
return; 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); const editorClone = editorNode.cloneNode(true);
// editorClone.querySelectorAll('.editor-quote').forEach(quote => quote.remove());
editorClone.querySelectorAll('.editor-quote').forEach(quote => quote.remove());
// alt let rawTextContent = editorClone.textContent || '';
// textContent += altText editorClone.textContent
// textContent
let rawTextContent = editorClone.textContent || '';
const emojiImages = editorClone.querySelectorAll('img.editor-emoji'); const emojiImages = editorClone.querySelectorAll('img.editor-emoji');
// if (emojiImages.length > 0) {
// TODO: editorContent.value
if (emojiImages.length > 0) {
emojiImages.forEach(emoji => { emojiImages.forEach(emoji => {
const altText = emoji.getAttribute('alt'); const altText = emoji.getAttribute('alt');
if (altText) { if (altText) {
// textContentimgalt rawTextContent += altText;
rawTextContent += altText;
} }
}); });
} }
editorContent.value = rawTextContent; 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'); const hasSpecialElements = editorNode.querySelector('img, .editor-file, .mention');
// if (currentText === '' && !hasSpecialElements) {
// if (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 <br> tag or other empty structures like <p><br></p>.
if (editorNode.innerHTML !== '') {
editorNode.innerHTML = ''; editorNode.innerHTML = '';
} }
} }
editorHtml.value = editorNode.innerHTML || ''; editorHtml.value = editorNode.innerHTML || '';
// TODO: parseEditorContent, saveDraft, emit input_event 使 (debounce) const currentEditorItems = parseEditorContent().items;
const currentEditorItems = parseEditorContent().items;
checkMention(target); checkMention(target);
saveDraft(); saveDraft();
@ -153,8 +138,7 @@ const handleInput = (event) => {
data: currentEditorItems.reduce((result, item) => { data: currentEditorItems.reduce((result, item) => {
if (item.type === 1) return result + item.content; if (item.type === 1) return result + item.content;
if (item.type === 3) return result + '[图片]'; if (item.type === 3) return result + '[图片]';
// TODO: return result;
return result;
}, '') }, '')
}); });
}; };
@ -239,8 +223,7 @@ const insertMention = (member, clonedRange) => {
const offset = range.startOffset; const offset = range.startOffset;
const textContent = textNode.nodeType === Node.TEXT_NODE ? textNode.textContent || '' : ''; 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'); const mentionSpan = document.createElement('span');
mentionSpan.className = 'mention'; mentionSpan.className = 'mention';
@ -248,28 +231,20 @@ const insertMention = (member, clonedRange) => {
mentionSpan.textContent = `@${member.value || member.nickname} `; mentionSpan.textContent = `@${member.value || member.nickname} `;
mentionSpan.contentEditable = 'false'; mentionSpan.contentEditable = 'false';
// '@' if (atIndex !== -1 && textNode.nodeType === Node.TEXT_NODE) {
if (atIndex !== -1 && textNode.nodeType === Node.TEXT_NODE) {
const parent = textNode.parentNode; const parent = textNode.parentNode;
if (!parent) return; if (!parent) return;
// '@' range.setStart(textNode, atIndex);
range.setStart(textNode, atIndex);
range.setEnd(textNode, offset); range.setEnd(textNode, offset);
range.deleteContents(); // ( '@' ) range.deleteContents();
range.insertNode(mentionSpan); } else {
range.insertNode(mentionSpan); // if (!range.collapsed) {
} else { range.deleteContents(); }
// '@'
if (!range.collapsed) {
range.deleteContents(); //
}
range.insertNode(mentionSpan); range.insertNode(mentionSpan);
} }
// 使 range.setStartAfter(mentionSpan);
//
range.setStartAfter(mentionSpan);
range.collapse(true); range.collapse(true);
selection.removeAllRanges(); selection.removeAllRanges();
@ -305,33 +280,26 @@ const handlePaste = (event) => {
image.src = tempUrl; image.src = tempUrl;
image.onload = () => { image.onload = () => {
URL.revokeObjectURL(tempUrl); // URL URL.revokeObjectURL(tempUrl); const form = new FormData();
const form = new FormData();
form.append('file', file); form.append('file', file);
form.append('source', 'fonchain-chat'); form.append('source', 'fonchain-chat');
form.append('urlParam', `width=${image.width}&height=${image.height}`); 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 }) => { uploadImg(form).then(({ code, data, message }) => {
if (code === 0 && data && data.ori_url) { if (code === 0 && data && data.ori_url) {
// src 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--) {
//
for (let j = editorImages.length - 1; j >= 0; j--) {
if (editorImages[j].src === tempUrl) { if (editorImages[j].src === tempUrl) {
editorImages[j].src = data.ori_url; 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; break;
} }
} }
handleInput({ target: editorRef.value }); // handleInput({ target: editorRef.value }); } else {
} else {
window['$message'].error(message || '图片上传失败'); 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--) { for (let j = editorImages.length - 1; j >= 0; j--) {
if (editorImages[j].src === tempUrl) { if (editorImages[j].src === tempUrl) {
editorImages[j].remove(); editorImages[j].remove();
@ -343,8 +311,7 @@ const handlePaste = (event) => {
}).catch(error => { }).catch(error => {
console.error('Upload image error:', error); console.error('Upload image error:', error);
window['$message'].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--) { for (let j = editorImages.length - 1; j >= 0; j--) {
if (editorImages[j].src === tempUrl) { if (editorImages[j].src === tempUrl) {
editorImages[j].remove(); editorImages[j].remove();
@ -358,14 +325,12 @@ const handlePaste = (event) => {
URL.revokeObjectURL(tempUrl); URL.revokeObjectURL(tempUrl);
window['$message'].error('无法加载粘贴的图片'); window['$message'].error('无法加载粘贴的图片');
}; };
return; // return; }
}
} }
} }
} }
// if (!imagePasted) {
if (!imagePasted) {
const text = clipboardData.getData('text/plain') || ''; const text = clipboardData.getData('text/plain') || '';
if (text) { if (text) {
const selection = window.getSelection(); const selection = window.getSelection();
@ -374,8 +339,7 @@ const handlePaste = (event) => {
range.deleteContents(); range.deleteContents();
const textNode = document.createTextNode(text); const textNode = document.createTextNode(text);
range.insertNode(textNode); range.insertNode(textNode);
// range.setStartAfter(textNode);
range.setStartAfter(textNode);
range.collapse(true); range.collapse(true);
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); selection.addRange(range);
@ -415,8 +379,7 @@ const handleKeydown = (event) => {
const editor = editorRef.value; const editor = editorRef.value;
if (!editor) return; if (!editor) return;
// if (showMention.value) {
if (showMention.value) {
const mentionUl = document.querySelector('.mention-list ul'); const mentionUl = document.querySelector('.mention-list ul');
let handled = false; let handled = false;
switch (event.key) { 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(); const selection = window.getSelection();
if (!selection || !selection.rangeCount) return; if (!selection || !selection.rangeCount) return;
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
if (range.collapsed) { if (range.collapsed) {
let nodeToCheck = null; let nodeToCheck = null;
let positionRelativeToCheck = ''; // 'before' or 'after' let positionRelativeToCheck = '';
const container = range.startContainer; const container = range.startContainer;
const offset = range.startOffset; const offset = range.startOffset;
if (event.key === 'Backspace') { if (event.key === 'Backspace') {
if (offset === 0) { // if (offset === 0) { nodeToCheck = container.previousSibling;
nodeToCheck = container.previousSibling;
positionRelativeToCheck = 'before'; positionRelativeToCheck = 'before';
} else if (container.nodeType === Node.ELEMENT_NODE && offset > 0) { } else if (container.nodeType === Node.ELEMENT_NODE && offset > 0) {
// nodeToCheck = container.childNodes[offset - 1];
nodeToCheck = container.childNodes[offset - 1];
positionRelativeToCheck = 'before'; positionRelativeToCheck = 'before';
} }
} else if (event.key === 'Delete') { } else if (event.key === 'Delete') {
if (container.nodeType === Node.TEXT_NODE && offset === container.textContent.length) { // if (container.nodeType === Node.TEXT_NODE && offset === container.textContent.length) { nodeToCheck = container.nextSibling;
nodeToCheck = container.nextSibling;
positionRelativeToCheck = 'after'; positionRelativeToCheck = 'after';
} else if (container.nodeType === Node.ELEMENT_NODE && offset < container.childNodes.length) { } else if (container.nodeType === Node.ELEMENT_NODE && offset < container.childNodes.length) {
// nodeToCheck = container.childNodes[offset];
nodeToCheck = container.childNodes[offset];
positionRelativeToCheck = 'after'; 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(); event.preventDefault();
const parent = nodeToCheck.parentNode; const parent = nodeToCheck.parentNode;
parent.removeChild(nodeToCheck); parent.removeChild(nodeToCheck);
// 使
handleInput({ target: editor }); handleInput({ target: editor });
return; 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(); event.preventDefault();
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
@ -533,8 +485,7 @@ const handleKeydown = (event) => {
return; return;
} }
// (Enter) if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
const messageData = parseEditorContent(); const messageData = parseEditorContent();
const isEmptyMessage = messageData.items.length === 0 || const isEmptyMessage = messageData.items.length === 0 ||
@ -572,17 +523,14 @@ const sendMessage = () => {
let finalItems = []; let finalItems = [];
if (parsedData && parsedData.items) { if (parsedData && parsedData.items) {
finalItems = parsedData.items.map(item => { finalItems = parsedData.items.map(item => {
if (item.type === 1 && typeof item.content === 'string') { // if (item.type === 1 && typeof item.content === 'string') { let content = cleanInvisibleChars(item.content);
let content = cleanInvisibleChars(item.content);
content = content.replace(/<br\s*\/?>/gi, '\n').trim(); content = content.replace(/<br\s*\/?>/gi, '\n').trim();
return { ...item, content }; return { ...item, content };
} }
return item; return item;
}).filter(item => { }).filter(item => {
if (item.type === 1 && !item.content && !(parsedData.mentionUids && parsedData.mentionUids.length > 0)) return false; 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 === 3 && !item.content) return false; if (item.type === 4 && !item.content) return false; return true;
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) { if (messageToSend.quoteId && quoteData.value && quoteData.value.id === messageToSend.quoteId) {
messageToSend.quote = { ...quoteData.value }; messageToSend.quote = { ...quoteData.value };
} else if (messageToSend.quoteId) { } 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 { } 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 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; const isSingleFileNoQuote = messageToSend.items.length === 1 && messageToSend.items[0].type === 4 && !messageToSend.quote;
@ -627,16 +570,13 @@ const sendMessage = () => {
size: imgItem.size || 0 size: imgItem.size || 0
})); }));
} else if (isSingleFileNoQuote) { } 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]; 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, url: fileItem.content,
name: fileItem.name, name: fileItem.name,
size: fileItem.size size: fileItem.size
})); }));
} else { } else {
// All other cases: text, mixed content, or items with quotes
emit('editor-event', emitCall('text_event', messageToSend)); emit('editor-event', emitCall('text_event', messageToSend));
} }
@ -655,19 +595,17 @@ const parseEditorContent = () => {
} }
const tempDiv = document.createElement('div'); 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'); const quoteElement = tempDiv.querySelector('.editor-quote');
if (quoteElement && quoteData.value && quoteData.value.id) { if (quoteElement && quoteData.value && quoteData.value.id) {
parsedQuoteId = quoteData.value.id; parsedQuoteId = quoteData.value.id;
quoteElement.remove(); // Remove from tempDiv to avoid parsing its content quoteElement.remove();
} }
let currentTextBuffer = ''; let currentTextBuffer = '';
const flushTextBufferIfNeeded = () => { 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) { if (currentTextBuffer) {
items.push({ type: 1, content: currentTextBuffer }); items.push({ type: 1, content: currentTextBuffer });
} }
@ -684,38 +622,36 @@ const parseEditorContent = () => {
switch (node.tagName) { switch (node.tagName) {
case 'BR': case 'BR':
currentTextBuffer += '\n'; // Represent <br> as newline in text content currentTextBuffer += '\n';
break; break;
case 'IMG': case 'IMG':
flushTextBufferIfNeeded(); flushTextBufferIfNeeded();
const src = node.getAttribute('src'); const src = node.getAttribute('src');
const alt = node.getAttribute('alt'); const alt = node.getAttribute('alt');
const isEmojiPic = node.classList.contains('editor-emoji'); 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) { if (isTextEmojiPlaceholder && alt) {
currentTextBuffer += alt; // Treat as text currentTextBuffer += alt;
} else if (src) { } else if (src) {
items.push({ items.push({
type: 3, // Image type: 3,
content: src, content: src,
isEmoji: isEmojiPic, isEmoji: isEmojiPic,
width: node.getAttribute('data-original-width') || node.width || null, width: node.getAttribute('data-original-width') || node.width || null,
height: node.getAttribute('data-original-height') || node.height || null, height: node.getAttribute('data-original-height') || node.height || null,
// size: node.getAttribute('data-size') || null, // If available
}); });
} }
break; break;
default: default:
if (node.classList.contains('mention')) { 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'); const userId = node.getAttribute('data-user-id');
if (userId) { if (userId) {
mentionUids.add(Number(userId)); mentionUids.add(Number(userId));
} }
currentTextBuffer += node.textContent || ''; // Add mention text to buffer currentTextBuffer += node.textContent || '';
} else if (node.classList.contains('editor-file')) { } else if (node.classList.contains('editor-file')) {
flushTextBufferIfNeeded(); flushTextBufferIfNeeded();
const fileUrl = node.getAttribute('data-url'); const fileUrl = node.getAttribute('data-url');
@ -723,7 +659,7 @@ const parseEditorContent = () => {
const fileSize = node.getAttribute('data-size-raw') || node.getAttribute('data-size') || 0; const fileSize = node.getAttribute('data-size-raw') || node.getAttribute('data-size') || 0;
if (fileUrl && fileName) { if (fileUrl && fileName) {
items.push({ items.push({
type: 4, // File type: 4,
content: fileUrl, content: fileUrl,
name: fileName, name: fileName,
size: parseInt(fileSize, 10), size: parseInt(fileSize, 10),
@ -739,7 +675,7 @@ const parseEditorContent = () => {
}; };
Array.from(tempDiv.childNodes).forEach(processNodeRecursively); Array.from(tempDiv.childNodes).forEach(processNodeRecursively);
flushTextBufferIfNeeded(); // Final flush for any remaining text flushTextBufferIfNeeded();
return { return {
items: items.length > 0 ? items : [{ type: 1, content: '' }], items: items.length > 0 ? items : [{ type: 1, content: '' }],
@ -757,33 +693,24 @@ const clearEditor = () => {
editorHtml.value = ''; editorHtml.value = '';
quoteData.value = null; quoteData.value = null;
// Reset mention related states
hideMentionList(); // This already handles showMention, mentionList, currentMentionQuery hideMentionList();
// Remove quote element from the DOM if it exists within the editor
const existingQuoteElement = editorRef.value ? editorRef.value.querySelector('.editor-quote') : null; const existingQuoteElement = editorRef.value ? editorRef.value.querySelector('.editor-quote') : null;
if (existingQuoteElement) { if (existingQuoteElement) {
existingQuoteElement.remove(); existingQuoteElement.remove();
} }
// saveDraft(); // Consider if saveDraft should be called. Clearing usually means discarding. handleInput();
// 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', { emit('editor-event', {
event: 'clear_event', // Or stick to 'input_event' if that's the convention event: 'clear_event',
data: '' data: ''
}); });
if (editorRef.value) { if (editorRef.value) {
nextTick(() => { nextTick(() => {
editorRef.value.focus(); editorRef.value.focus();
// Ensure focusplaceholder if (editorRef.value && editorRef.value.innerHTML.toLowerCase() === '<br>') {
if (editorRef.value && editorRef.value.innerHTML.toLowerCase() === '<br>') {
editorRef.value.innerHTML = ''; editorRef.value.innerHTML = '';
} }
}); });
@ -797,10 +724,10 @@ const insertImage = (fileOrSrc, isUploaded = false, uploadedUrl = '') => {
if (!editorRef.value) return; if (!editorRef.value) return;
const img = document.createElement('img'); const img = document.createElement('img');
img.className = 'editor-image'; // Keep existing class if it's styled img.className = 'editor-image';
img.alt = '图片'; // Default alt text img.alt = '图片';
img.style.maxWidth = '200px'; // Standardized max width img.style.maxWidth = '200px';
img.style.maxHeight = '200px'; // Standardized max height img.style.maxHeight = '200px';
img.style.borderRadius = '4px'; img.style.borderRadius = '4px';
img.style.objectFit = 'contain'; img.style.objectFit = 'contain';
img.style.margin = '5px'; img.style.margin = '5px';
@ -825,20 +752,18 @@ const insertImage = (fileOrSrc, isUploaded = false, uploadedUrl = '') => {
editorRef.value.focus(); editorRef.value.focus();
range = document.createRange(); range = document.createRange();
range.selectNodeContents(editorRef.value); range.selectNodeContents(editorRef.value);
range.collapse(false); // End of editor range.collapse(false);
} }
} else { } else {
editorRef.value.focus(); editorRef.value.focus();
range = document.createRange(); range = document.createRange();
range.selectNodeContents(editorRef.value); range.selectNodeContents(editorRef.value);
range.collapse(false); // End of editor range.collapse(false);
} }
range.deleteContents(); range.deleteContents();
range.insertNode(img); range.insertNode(img);
const spaceNode = document.createTextNode('\u00A0');
// Add a space after the image for better typing experience
const spaceNode = document.createTextNode('\u00A0'); // Non-breaking space
range.insertNode(spaceNode); range.insertNode(spaceNode);
range.setStartAfter(spaceNode); range.setStartAfter(spaceNode);
range.collapse(true); range.collapse(true);
@ -846,20 +771,20 @@ const insertImage = (fileOrSrc, isUploaded = false, uploadedUrl = '') => {
selection.addRange(range); selection.addRange(range);
editorRef.value.focus(); 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(); const tempImageForSize = new Image();
tempImageForSize.onload = () => { tempImageForSize.onload = () => {
setupAndInsert(fileOrSrc, tempImageForSize.naturalWidth, tempImageForSize.naturalHeight); setupAndInsert(fileOrSrc, tempImageForSize.naturalWidth, tempImageForSize.naturalHeight);
}; };
tempImageForSize.onerror = () => { tempImageForSize.onerror = () => {
console.warn('Failed to load image from URL for size calculation:', fileOrSrc); 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; 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(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
const dataUrl = e.target.result; const dataUrl = e.target.result;
@ -869,7 +794,7 @@ const insertImage = (fileOrSrc, isUploaded = false, uploadedUrl = '') => {
}; };
tempImageForSize.onerror = () => { tempImageForSize.onerror = () => {
console.warn('Failed to load image from FileReader for size calculation.'); console.warn('Failed to load image from FileReader for size calculation.');
setupAndInsert(dataUrl); // Insert even if size calculation fails setupAndInsert(dataUrl);
}; };
tempImageForSize.src = dataUrl; tempImageForSize.src = dataUrl;
}; };
@ -904,24 +829,18 @@ const onUploadSendImg = async (event) => {
console.warn('Invalid file type for image upload:', file.type); console.warn('Invalid file type for image upload:', file.type);
continue; continue;
} }
insertImage(file, false);
// Optimistically insert a local preview using the already optimized insertImage function
insertImage(file, false); // isUploaded = false, uploadedUrl = ''
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('source', 'fonchain-chat'); // Consider making 'source' configurable formData.append('source', 'fonchain-chat');
try { try {
const res = await uploadImg(formData); const res = await uploadImg(formData);
if (res && res.status === 0 && res.data && res.data.ori_url) { 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])'); const previewImages = editorRef.value.querySelectorAll('img[data-status="local-preview"][src^="data:image"]:not([data-uploaded-url])');
let replacedPreview = false; let replacedPreview = false;
if (previewImages.length > 0) { if (previewImages.length > 0) {
// Try to find the correct preview. Assuming the last one is the most recent.
const lastPreviewImage = previewImages[previewImages.length - 1]; const lastPreviewImage = previewImages[previewImages.length - 1];
if (lastPreviewImage && lastPreviewImage.src.startsWith('data:image')) { if (lastPreviewImage && lastPreviewImage.src.startsWith('data:image')) {
lastPreviewImage.src = res.data.ori_url; 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.width) lastPreviewImage.setAttribute('data-original-width', res.data.width);
if (res.data.height) lastPreviewImage.setAttribute('data-original-height', res.data.height); if (res.data.height) lastPreviewImage.setAttribute('data-original-height', res.data.height);
replacedPreview = true; replacedPreview = true;
handleInput(); // Update editor state after modifying the image handleInput();
} }
} }
if (!replacedPreview) { if (!replacedPreview) {
// If preview wasn't found/replaced, insert the uploaded image anew.
insertImage(res.data.ori_url, true, res.data.ori_url); 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', { emit('editor-event', emitCall('image_event', {
url: res.data.ori_url, url: res.data.ori_url,
width: res.data.width || 0, width: res.data.width || 0,
@ -953,7 +866,6 @@ const onUploadSendImg = async (event) => {
} else { } else {
console.error('Image upload failed or received invalid response:', res); 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])'); const previewImages = editorRef.value.querySelectorAll('img[data-status="local-preview"][src^="data:image"]:not([data-uploaded-url])');
if (previewImages.length > 0) { if (previewImages.length > 0) {
const lastPreviewImage = previewImages[previewImages.length -1]; 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) { async function onUploadFile(e) {
if (!e.target || !e.target.files || e.target.files.length === 0) return; if (!e.target || !e.target.files || e.target.files.length === 0) return;
const file = e.target.files[0]; 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; e.target.value = null;
const fileType = file.type; const fileType = file.type;
@ -989,42 +900,16 @@ async function onUploadFile(e) {
if (fileType.startsWith('image/')) { if (fileType.startsWith('image/')) {
eventName = 'image_event'; 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)); emit('editor-event', emitCall(eventName, file));
} else if (fileType.startsWith('video/')) { } else if (fileType.startsWith('video/')) {
eventName = 'video_event'; eventName = 'video_event';
emit('editor-event', emitCall(eventName, file)); emit('editor-event', emitCall(eventName, file));
} else { } else {
eventName = 'file_event'; 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)); 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) { if (selection && selection.rangeCount > 0) {
range = selection.getRangeAt(0); range = selection.getRangeAt(0);
if (!editor.contains(range.commonAncestorContainer)) { if (!editor.contains(range.commonAncestorContainer)) {
// Range is outside the editor, reset to end of editor
range = document.createRange(); range = document.createRange();
range.selectNodeContents(editor); range.selectNodeContents(editor);
range.collapse(false); range.collapse(false);
} }
} else { } else {
// No selection, create range at the end of the editor
range = document.createRange(); range = document.createRange();
range.selectNodeContents(editor); range.selectNodeContents(editor);
range.collapse(false); range.collapse(false);
} }
range.deleteContents(); // Clear any selected text or prepare cursor position range.deleteContents();
const textNode = document.createTextNode(emojiText); const textNode = document.createTextNode(emojiText);
range.insertNode(textNode); range.insertNode(textNode);
// Move cursor after the inserted text node
range.setStartAfter(textNode); range.setStartAfter(textNode);
range.collapse(true); range.collapse(true);
selection.removeAllRanges(); // Deselect previous range selection.removeAllRanges();
selection.addRange(range); // Apply new range selection.addRange(range);
handleInput(); // Update editor state handleInput();
}; };
@ -1114,35 +998,29 @@ const insertImageEmoji = (imgSrc, altText) => {
if (!editor.contains(range.commonAncestorContainer)) { if (!editor.contains(range.commonAncestorContainer)) {
range = document.createRange(); range = document.createRange();
range.selectNodeContents(editor); range.selectNodeContents(editor);
range.collapse(false); // Move to the end range.collapse(false);
} }
} else { } else {
range = document.createRange(); range = document.createRange();
range.selectNodeContents(editor); 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'); const img = document.createElement('img');
img.src = imgSrc; img.src = imgSrc;
img.alt = altText || 'emoji'; // Provide a default alt text img.alt = altText || 'emoji';
img.className = 'editor-emoji'; // Class for styling img.className = 'editor-emoji';
img.setAttribute('data-role', 'emoji'); // For easier identification img.setAttribute('data-role', 'emoji');
// 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); range.insertNode(img);
// Insert a space after the emoji for better typing experience const spaceNode = document.createTextNode('\u00A0');
const spaceNode = document.createTextNode('\u00A0'); // Non-breaking space, or use ' '
range.setStartAfter(img); range.setStartAfter(img);
range.collapse(true); range.collapse(true);
range.insertNode(spaceNode); range.insertNode(spaceNode);
// Move cursor after the space
range.setStartAfter(spaceNode); range.setStartAfter(spaceNode);
range.collapse(true); range.collapse(true);
@ -1151,7 +1029,7 @@ const insertImageEmoji = (imgSrc, altText) => {
selection.addRange(range); selection.addRange(range);
} }
handleInput(); // Update editor state handleInput();
}; };
@ -1160,7 +1038,7 @@ const onSubscribeMention = async (data) => {
const editorNode = editorRef.value; const editorNode = editorRef.value;
editorNode.focus(); editorNode.focus();
await nextTick(); // Ensure focus and DOM updates are processed await nextTick();
let selection = window.getSelection(); let selection = window.getSelection();
let range; let range;
@ -1168,29 +1046,27 @@ const onSubscribeMention = async (data) => {
if (selection && selection.rangeCount > 0) { if (selection && selection.rangeCount > 0) {
range = selection.getRangeAt(0); range = selection.getRangeAt(0);
if (!editorNode.contains(range.commonAncestorContainer)) { if (!editorNode.contains(range.commonAncestorContainer)) {
// If current selection is outside editor, move to the end of the editor
range = document.createRange(); range = document.createRange();
range.selectNodeContents(editorNode); range.selectNodeContents(editorNode);
range.collapse(false); // false to collapse to the end range.collapse(false);
} }
} else { } else {
// No selection or invalid selection, create a new range at the end of the editor
range = document.createRange(); range = document.createRange();
range.selectNodeContents(editorNode); range.selectNodeContents(editorNode);
range.collapse(false); range.collapse(false);
} }
// Ensure selection is updated with the correct range
if (selection) { if (selection) {
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); selection.addRange(range);
insertMention(data, range); // Pass the live range to insertMention insertMention(data, range);
} else { } else {
// Fallback if selection is null for some reason (should be rare)
const fallbackRange = document.createRange(); const fallbackRange = document.createRange();
fallbackRange.selectNodeContents(editorNode); fallbackRange.selectNodeContents(editorNode);
fallbackRange.collapse(false); fallbackRange.collapse(false);
const newSelection = window.getSelection(); // Attempt to re-get selection const newSelection = window.getSelection();
if (newSelection){ if (newSelection){
newSelection.removeAllRanges(); newSelection.removeAllRanges();
newSelection.addRange(fallbackRange); newSelection.addRange(fallbackRange);
@ -1201,10 +1077,8 @@ const onSubscribeMention = async (data) => {
} }
}; };
// handleDeleteQuote
const handleDeleteQuote = function(e) { const handleDeleteQuote = function(e) {
// 退 if (e.key !== 'Backspace' && e.key !== 'Delete') return;
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
const selection = window.getSelection(); const selection = window.getSelection();
if (selection.rangeCount === 0) return; if (selection.rangeCount === 0) return;
@ -1216,36 +1090,29 @@ const handleDeleteQuote = function(e) {
const quoteElement = editor.querySelector('.editor-quote'); const quoteElement = editor.querySelector('.editor-quote');
if (!quoteElement) { if (!quoteElement) {
// editor.removeEventListener('keydown', handleDeleteQuote);
editor.removeEventListener('keydown', handleDeleteQuote);
return; 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.collapsed &&
range.startContainer === editor && range.startContainer === editor &&
quoteIndex === range.startOffset; quoteIndex === range.startOffset;
// const isAfterQuote = e.key === 'Delete' &&
const isAfterQuote = e.key === 'Delete' &&
range.collapsed && range.collapsed &&
range.startContainer === editor && range.startContainer === editor &&
quoteIndex === range.startOffset - 1; quoteIndex === range.startOffset - 1;
if (isBeforeQuote || isAfterQuote) { if (isBeforeQuote || isAfterQuote) {
// e.preventDefault();
e.preventDefault();
// quoteElement.remove();
quoteElement.remove();
quoteData.value = null; quoteData.value = null;
// handleInput({ target: editor });
handleInput({ target: editor });
} }
}; };
@ -1254,10 +1121,7 @@ const onSubscribeQuote = (data) => {
quoteData.value = data; quoteData.value = data;
const editor = editorRef.value; const editor = editorRef.value;
// Remove existing quotes
editor.querySelectorAll('.editor-quote').forEach(quote => quote.remove()); editor.querySelectorAll('.editor-quote').forEach(quote => quote.remove());
// Save current selection if it's within the editor
const selection = window.getSelection(); const selection = window.getSelection();
let savedRange = null; let savedRange = null;
if (selection && selection.rangeCount > 0) { if (selection && selection.rangeCount > 0) {
@ -1267,7 +1131,6 @@ const onSubscribeQuote = (data) => {
} }
} }
// Create quote element safely
const quoteElement = document.createElement('div'); const quoteElement = document.createElement('div');
quoteElement.className = 'editor-quote'; quoteElement.className = 'editor-quote';
quoteElement.contentEditable = 'false'; quoteElement.contentEditable = 'false';
@ -1298,15 +1161,12 @@ const onSubscribeQuote = (data) => {
closeButton.className = 'quote-close'; closeButton.className = 'quote-close';
closeButton.textContent = '×'; closeButton.textContent = '×';
quoteElement.appendChild(closeButton); quoteElement.appendChild(closeButton);
// Insert quote at the beginning
if (editor.firstChild) { if (editor.firstChild) {
editor.insertBefore(quoteElement, editor.firstChild); editor.insertBefore(quoteElement, editor.firstChild);
} else { } else {
editor.appendChild(quoteElement); editor.appendChild(quoteElement);
} }
// Ensure there's a node (like a zero-width space) after the quote for cursor placement
let nodeToPlaceCursorAfter = quoteElement; let nodeToPlaceCursorAfter = quoteElement;
const zeroWidthSpace = document.createTextNode('\u200B'); const zeroWidthSpace = document.createTextNode('\u200B');
if (editor.lastChild === quoteElement || !quoteElement.nextSibling) { if (editor.lastChild === quoteElement || !quoteElement.nextSibling) {
@ -1325,11 +1185,10 @@ const onSubscribeQuote = (data) => {
nodeToPlaceCursorAfter.remove(); nodeToPlaceCursorAfter.remove();
} }
quoteData.value = null; quoteData.value = null;
editor.removeEventListener('keydown', handleDeleteQuote); // Clean up listener editor.removeEventListener('keydown', handleDeleteQuote); handleInput();
handleInput(); // Update editor state
editor.focus(); editor.focus();
} else { } else {
// Click on quote content, move cursor after the quote (or after the zeroWidthSpace)
const newRange = document.createRange(); const newRange = document.createRange();
newRange.setStartAfter(nodeToPlaceCursorAfter.parentNode === editor ? nodeToPlaceCursorAfter : quoteElement); newRange.setStartAfter(nodeToPlaceCursorAfter.parentNode === editor ? nodeToPlaceCursorAfter : quoteElement);
newRange.collapse(true); newRange.collapse(true);
@ -1342,43 +1201,35 @@ const onSubscribeQuote = (data) => {
}; };
quoteElement.addEventListener('click', handleQuoteClick); 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(); editor.focus();
const newSelection = window.getSelection(); const newSelection = window.getSelection();
if (!newSelection) return; if (!newSelection) return;
let cursorPlaced = false; let cursorPlaced = false;
// Try to restore saved range if it's still valid if (savedRange) {
if (savedRange) {
try { 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.removeAllRanges();
newSelection.addRange(savedRange); newSelection.addRange(savedRange);
cursorPlaced = true; cursorPlaced = true;
} }
} catch (err) { } catch (err) {
// If restoring fails, fallback to placing cursor after quote }
}
} }
if (!cursorPlaced) { if (!cursorPlaced) {
const newRange = document.createRange(); 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); newRange.setStartAfter(nodeToPlaceCursorAfter);
} else if (quoteElement.parentNode === editor && quoteElement.nextSibling) { } 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) { } else if (quoteElement.parentNode === editor) {
// Fallback to after quote element itself if it's the last child newRange.setStartAfter(quoteElement);
newRange.setStartAfter(quoteElement);
} else { } else {
// Ultimate fallback: end of editor newRange.selectNodeContents(editor);
newRange.selectNodeContents(editor);
newRange.collapse(false); newRange.collapse(false);
} }
newRange.collapse(true); newRange.collapse(true);
@ -1386,10 +1237,7 @@ const onSubscribeQuote = (data) => {
newSelection.addRange(newRange); newSelection.addRange(newRange);
} }
editor.scrollTop = editor.scrollHeight; // Scroll to bottom if needed editor.scrollTop = editor.scrollHeight; handleInput(); }, 0); };
handleInput(); // Update editor state
}, 0); // A small delay like 0 or 50ms is usually enough
};
const onSubscribeEdit = (data) => { const onSubscribeEdit = (data) => {
editingMessage.value = data editingMessage.value = data

View File

@ -57,7 +57,6 @@ const config = {
}, },
documentType, documentType,
editorConfig: { editorConfig: {
mode: 'view', mode: 'view',
lang: 'zh-CN', lang: 'zh-CN',
user: { user: {