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.');
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) {
// textContentimgalt
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 focusplaceholder
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

View File

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