Files
min-offer-forum-issue/js/index.js
DESKTOP-RQ919RC\Pc ad975d5c25 feat(编辑器): 实现富文本编辑功能并优化交互体验
添加富文本编辑功能,包括插入图片、表情和标签
优化键盘事件处理,支持特殊文本块操作
增加编辑器空状态提示和样式优化
修复光标定位和滚动行为问题
2025-10-10 18:57:05 +08:00

440 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { createApp, ref, onMounted, nextTick, onUnmounted, computed, watch } = Vue;
// 聚焦正文时间 图片表情包等操作放在 键盘上
createApp({
setup() {
let titleLength = ref(200);
let title = ref("");
let info = ref({
anonymity: 0,
});
const titleTextarea = ref(null);
const adjustTextareaHeight = () => {
nextTick(() => {
const textarea = titleTextarea.value;
if (!textarea) return;
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";
});
};
watch(title, () => {
adjustTextareaHeight();
});
const editorRef = ref(null);
onMounted(() => {
document.addEventListener("selectionchange", getFocusedNodeName);
// 添加键盘事件监听
document.addEventListener("keydown", handleDeleteKey);
});
onUnmounted(() => {
document.removeEventListener("selectionchange", getFocusedNodeName);
document.removeEventListener("keydown", handleDeleteKey);
});
// 处理删除键和回车键事件
const handleDeleteKey = (e) => {
// 处理删除键Backspace和Delete
if (e.key === "Backspace" || e.key === "Delete") {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
let currentNode = range.startContainer;
// 如果是文本节点,取其父元素
if (currentNode.nodeType === Node.TEXT_NODE) {
currentNode = currentNode.parentNode;
}
// 检查当前节点或其祖先是否是span.blue元素
let blueSpan = null;
let tempNode = currentNode;
while (tempNode && tempNode !== editorRef.value) {
if (tempNode.classList && tempNode.classList.contains("blue")) {
blueSpan = tempNode;
break;
}
tempNode = tempNode.parentNode;
}
// 如果在span.blue内且按下删除键阻止默认行为并删除整个span.blue元素
if (blueSpan) {
e.preventDefault();
// 删除整个span.blue元素
const parentNode = blueSpan.parentNode;
const rangeBefore = document.createRange();
rangeBefore.setStartBefore(blueSpan);
rangeBefore.setEndBefore(blueSpan);
// 保存删除前的位置,用于删除后设置光标
const startOffset = rangeBefore.toString().length;
// 删除元素
parentNode.removeChild(blueSpan);
// 设置光标到删除位置
const newRange = document.createRange();
const textNodes = [];
let currentTextNode = parentNode.firstChild;
let currentLength = 0;
while (currentTextNode && currentLength < startOffset) {
if (currentTextNode.nodeType === Node.TEXT_NODE) {
textNodes.push(currentTextNode);
currentLength += currentTextNode.nodeValue.length;
}
currentTextNode = currentTextNode.nextSibling;
}
if (textNodes.length > 0) {
const targetNode = textNodes[textNodes.length - 1];
const offset = Math.min(startOffset - (currentLength - targetNode.nodeValue.length), targetNode.nodeValue.length);
newRange.setStart(targetNode, offset);
newRange.setEnd(targetNode, offset);
selection.removeAllRanges();
selection.addRange(newRange);
lastSelection = newRange;
}
}
}
// 处理回车键
else if (e.key === "Enter") {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
let currentNode = range.startContainer;
// 如果是文本节点,取其父元素
if (currentNode.nodeType === Node.TEXT_NODE) {
currentNode = currentNode.parentNode;
}
// 检查当前节点或其祖先是否是span.blue元素
let blueSpan = null;
let tempNode = currentNode;
while (tempNode && tempNode !== editorRef.value) {
if (tempNode.classList && tempNode.classList.contains("blue")) {
blueSpan = tempNode;
break;
}
tempNode = tempNode.parentNode;
}
// 如果在span.blue内且按下回车键阻止默认行为并将光标后面的内容提取到新的span.blue中
if (blueSpan && range.startContainer.nodeType === Node.TEXT_NODE) {
e.preventDefault();
// 获取当前文本节点和偏移量
const textNode = range.startContainer;
const offset = range.startOffset;
const textContent = textNode.nodeValue;
// 如果光标在文本末尾,不做任何处理
if (offset >= textContent.length) {
// 创建新的span.blue元素并插入到当前span后面
const newBlueSpan = document.createElement("span");
// newBlueSpan.className = "blue";
// 创建换行符
const br = document.createElement("br");
blueSpan.parentNode.insertBefore(br, blueSpan.nextSibling);
blueSpan.parentNode.insertBefore(newBlueSpan, br.nextSibling);
// 设置光标到新的span.blue元素内
const newRange = document.createRange();
newRange.setStart(newBlueSpan, 0);
newRange.setEnd(newBlueSpan, 0);
selection.removeAllRanges();
selection.addRange(newRange);
lastSelection = newRange;
} else {
// 分割文本节点
const newTextNode = textNode.splitText(offset);
// 创建新的span.blue元素
const newBlueSpan = document.createElement("span");
// newBlueSpan.className = "blue";
// 将分割后的文本节点移动到新的span.blue元素中
newBlueSpan.appendChild(newTextNode.cloneNode(true));
// 删除原分割后的文本节点
newTextNode.parentNode.removeChild(newTextNode);
// 创建换行符
const br = document.createElement("br");
// 插入换行符和新的span.blue元素
blueSpan.parentNode.insertBefore(br, blueSpan.nextSibling);
blueSpan.parentNode.insertBefore(newBlueSpan, br.nextSibling);
// 设置光标到新的span.blue元素内的文本开始位置
const newRange = document.createRange();
newRange.setStart(newBlueSpan.firstChild, 0);
newRange.setEnd(newBlueSpan.firstChild, 0);
selection.removeAllRanges();
selection.addRange(newRange);
lastSelection = newRange;
}
}
}
};
// 获取当前焦点所在的节点名称(仅在.editor内
const getFocusedNodeName = () => {
const selection = window.getSelection();
if (!selection.rangeCount) return null;
// 获取焦点所在的节点
let focusedNode = selection.focusNode;
// 如果是文本节点,取其父元素
if (focusedNode.nodeType === Node.TEXT_NODE) {
focusedNode = focusedNode.parentNode;
}
// 检查节点是否在.editor容器内
const isInEditor = editorRef.value.contains(focusedNode);
if (!isInEditor) return null;
lastSelection = selection.getRangeAt(0);
console.log("更新选区");
updatePTitleStatus();
};
const isPTitle = ref(false);
// 初始化时设置lastSelection为第一个有效位置
let lastSelection = null;
onMounted(() => {
setTimeout(() => {
focusLastNode();
}, 1000);
});
const focusLastNode = () => {
const editor = document.getElementById("editor");
const selection = window.getSelection();
// 清除现有选择范围
selection.removeAllRanges();
// 创建新的范围对象
const range = document.createRange();
// 找到最后一个有效子节点(跳过空白文本节点)
let lastNode = editor.lastChild;
while (lastNode) {
// 检查是否为有效节点(非空白文本节点)
if (!(lastNode.nodeType === 3 && lastNode.textContent.trim() === "")) {
break;
}
lastNode = lastNode.previousSibling;
}
if (lastNode) {
// 设置范围到最后一个节点的末尾
range.setStartAfter(lastNode);
range.setEndAfter(lastNode);
} else {
// 如果编辑器为空,选择整个编辑器
range.selectNodeContents(editor);
range.collapse(false);
}
// 将范围添加到选择对象,不设置焦点
selection.addRange(range);
};
let isEmpty = ref(true);
const onEditorInput = (event) => {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
lastSelection = selection.getRangeAt(0);
console.log("更新选区");
updatePTitleStatus();
}
judgeIsEmpty();
};
// 判断是否为空
const judgeIsEmpty = () => {
const text = editorRef.value.innerText;
console.log("text", text);
isEmpty.value = text.length == 0 && !editorRef.value.querySelector("img");
};
const paragraphTitle = () => {
// 保存当前滚动位置
const scrollTop = window.scrollY;
editorRef.value.focus();
if (!lastSelection) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(lastSelection);
// 使用try-catch确保即使命令执行失败也能恢复滚动位置
try {
document.execCommand("formatBlock", false, isPTitle.value ? "P" : "H2");
} catch (error) {
console.error("应用段落格式失败:", error);
}
// 立即恢复滚动位置
window.scrollTo(0, scrollTop);
// 更新状态
updatePTitleStatus();
};
const updatePTitleStatus = () => {
if (lastSelection) {
const node = lastSelection.commonAncestorContainer;
let parentElement = node.parentElement;
isPTitle.value = node.nodeName === "H2" || (node.nodeType === Node.TEXT_NODE && node.parentNode?.nodeName === "H2");
// 死循环,直到遇到终止条件
while (true) {
// 如果没有父元素了到达文档根节点退出循环返回false
if (!parentElement) {
isPTitle.value = false;
return;
}
// 遇到id为"editor"的元素返回false
if (parentElement.id === "editor") {
isPTitle.value = false;
return;
}
// 遇到nodeName为"H2"的元素返回true注意nodeName是大写的
if (parentElement.nodeName === "H2") {
isPTitle.value = true;
return;
}
// 继续向上查找父元素
parentElement = parentElement.parentElement;
}
}
};
const maxSize = 20 * 1024 * 1024; // 20MB
const insertImage = (event) => {
const file = event.target.files[0];
if (!file) return; // 处理未选择文件的情况
if (file.size > maxSize) {
console.log("文件大小不能超过 20MB");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const imgSrc = e.target.result;
console.log("imgSrc", imgSrc);
try {
const range = lastSelection;
const img = document.createElement("img");
img.src = imgSrc;
range.insertNode(img);
const div = document.createElement("div");
range.insertNode(div);
judgeIsEmpty();
} catch (error) {
console.error("插入图片出错:", error);
}
};
reader.readAsDataURL(file);
};
const insertLabel = (label) => {
const span = document.createElement("span");
span.innerHTML = `<span class="blue">#${label}</span> <span class="fill"></span> `;
lastSelection.insertNode(span);
// 移动光标到emoji后面
lastSelection.setStartAfter(span);
lastSelection.setEndAfter(span);
judgeIsEmpty();
};
let emojiState = ref(false);
const optionEmoji = ref(["😀", "😁", "😆", "😅", "😂", "😉", "😍", "🥰", "😘", "🤥", "😪", "😵‍💫", "🤓", "🥺", "😋", "😜", "🤪", "😎", "🤩", "🥳", "😔", "🙁", "😭", "😡", "😳", "🤗", "🤔", "🤭", "🤫", "😯", "😵", "🙄", "🥴", "🤢", "🤑", "🤠", "👌", "✌️", "🤟", "🤘", "🤙", "👍", "👎", "✊", "👏", "🤝", "🙏", "💪", "❎️", "✳️", "✴️", "❇️", "#️⃣", "*️⃣", "1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣", "🔟", "🆗", "🈶", "🉐", "🉑", "🌹", "🥀", "🌸", "🌺", "🌷", "🌲", "☘️", "🍀", "🍁", "🌙", "⭐", "🌍", "☀️", "⭐️", "🌟", "☁️", "🌈", "☂️", "❄️", "☃️", "☄️", "🔥", "💧", "🍎", "🍐", "🍊", "🍉", "🍓", "🍑", "🍔", "🍟", "🍕", "🥪", "🍜", "🍡", "🍨", "🍦", "🎂", "🍰", "🍭", "🍿", "🍩", "🧃", "🍹", "🍒", "🥝", "🥒", "🥦", "🥨", "🌭", "🥘", "🍱", "🍢", "🥮", "🍩", "🍪", "🧁", "🍵", "🍶", "🍻", "🥂", "🧋", "🎉", "🎁", "🧧", "🎃", "🎄", "🧨", "✨️", "🎈", "🎊", "🎋", "🎍", "🎀", "🎖️", "🏆️", "🏅", "💌", "📬", "🚗", "🚕", "🚲", "🛵", "🚀", "🚁", "⛵", "🚢", "🔮", "🧸", "🀄️"]);
const openEmoji = () => (emojiState.value = true);
const closeEmoji = () => (emojiState.value = false);
const selectEmoji = (emoji) => {
const textNode = document.createTextNode(emoji);
lastSelection.insertNode(textNode);
// 移动光标到emoji后面
lastSelection.setStartAfter(textNode);
lastSelection.setEndAfter(textNode);
judgeIsEmpty();
getCursorPosition();
};
const getCursorPosition = () => {
const range = lastSelection;
const tempElement = document.createElement("span");
tempElement.classList.add("cursor");
// 在光标位置插入临时元素
range.insertNode(tempElement);
// 获取临时元素的位置信息
const rect = tempElement.getBoundingClientRect();
// 可视窗口高度
const viewportHeight = window.innerHeight;
// 计算目标位置中间偏上视口高度的30%位置)
// 公式:元素顶部相对于视口的位置 + 滚动距离 - 目标位置视口高度的30%
const targetPosition = window.scrollY + rect.top - viewportHeight * 0.3; // 30%位置,比正中间更靠上
// 平滑滚动到目标位置
window.scrollTo({
top: targetPosition,
behavior: "smooth", // 平滑滚动,移除则为瞬间滚动
});
// 移除临时元素
tempElement.parentNode.removeChild(tempElement);
};
const cutAnonymity = () => (info.value.anonymity = info.value.anonymity ? 0 : 1);
return { cutAnonymity, isEmpty, selectEmoji, closeEmoji, openEmoji, optionEmoji, emojiState, insertLabel, editorRef, info, title, titleLength, titleTextarea, adjustTextareaHeight, isPTitle, paragraphTitle, insertImage, onEditorInput };
},
}).mount("#appIndex");