在 Web 开发中,经常需要使用富文本编辑器来编辑和展示内容。wangeditor 是一个强大的富文本编辑器,提供了丰富的功能和灵活的配置,但是官方并没有提供目录导航和代码块的复制功能,所以我自己搞了一个
<template><div class="editor" flex w-full><!-- 文章内容 --><div flex-grow overflow-hidden w-full><slot/><div><Editorref="editorContent" v-model="defaultHtml":defaultConfig="editorConfig":mode="mode"m-t-60pxoverflow-hiddenw-auto@onChange="handleChange"@onCreated="handleCreated"/></div></div><div v-if="directory" class="flex-container" flex-none h-500px ml-20px p-t-160px relative w-300px><el-affix :offset="10"><div border-b border-b-solid border-gray200 class="table-of-title" flex font-bold items-center p-10px w-300px><el-icon><Expand/></el-icon><span ml-5px>目录</span></div><!-- 目录 --><div v-if="tableOfContents.length > 0" b-rd-5px class="table-of-contents " max-h-450px overflow-y-scroll p-10px relativew-300px><!-- 目录内容 --><ul list-none p-l-0><li v-for="(item, index) in tableOfContents" :key="item.id" :style="{ paddingLeft: item.level * 20 + 'px' }" border-rd mb-5pxpy-3px><a :class="{ active: activeIndex === index }" :href="`#${item.id}`" block decoration-none@click="handleItemClick(index)">{{ item.text }}</a></li></ul></div></el-affix></div></div>
</template><script lang="ts" setup>
import {Editor} from "@wangeditor/editor-for-vue";
import {copy} from "@/utils";// API 引用
import {upload} from "@/utils/request";const props = defineProps({modelValue: {type: [String],default: "",},directory: {type: [Boolean],default: true,}
});const emit = defineEmits(["update:modelValue"]);const defaultHtml = useVModel(props, "modelValue", emit);const editorRef = shallowRef(); // 编辑器实例,必须用 shallowRef
const mode = ref("default"); // 编辑器模式
// 在编辑器创建后生成目录
const tableOfContents = ref([]);
// 编辑器配置
const editorConfig = ref({placeholder: "请输入内容...",MENU_CONF: {uploadImage: {// 自定义图片上传async customUpload(file: any, insertFn: any) {const formData = new FormData();formData.set("file", file);upload(formData).then(({data: res}) => {insertFn(res.url);});},},},readOnly: true,
});const handleCreated = (editor: any) => {editorRef.value = editor; // 记录 editor 实例,重要!
};function handleChange(editor: any) {emit("update:modelValue", editor.getHtml());
}// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {const editor = editorRef.value;if (editor == null) return;editor.destroy();
});
watch(() => props.modelValue, (newVal) => {defaultHtml.value = newVal;
});
// 添加复制按钮的逻辑
const copyCode = () => {const codeBlocks = document.querySelectorAll(".editor pre > code");codeBlocks.forEach((codeBlock) => {// 创建复制按钮const copyButton = document.createElement("button");copyButton.innerText = "复制";copyButton.classList.add("copy-button");// 为复制按钮添加点击事件处理程序copyButton.addEventListener("click", () => {const codeText = codeBlock.querySelector("span").textContent;copy(codeText, "已复制", false);// 修改按钮文本为 "已复制"copyButton.innerText = "已复制";// 延迟一段时间后恢复按钮文本为 "复制"setTimeout(() => {copyButton.innerText = "复制";}, 3000); // 毫秒为单位,您可以调整时间长度});// 将复制按钮添加到代码块的父级元素中codeBlock.appendChild(copyButton);});
};// 添加锚点
const addAnchorLinks = () => {const headings = document.querySelectorAll(".editor h1, .editor h2, .editor h3");headings.forEach((heading, index) => {const anchorLink = document.createElement("a");anchorLink.setAttribute("href", `#section-${index + 1}`);// anchorLink.textContent = heading.textContent; // 设置锚点文本为标题文本anchorLink.style.pointerEvents = "none"; // 设置 pointer-events 为 none,使链接不可点击// 设置标题的id属性heading.setAttribute("id", `section-${index + 1}`);// 将锚点链接插入到标题内heading.innerHTML = anchorLink.outerHTML + heading.innerHTML;});
};// 更新目录项点击事件处理函数
const handleItemClick = (index: number) => {activeIndex.value = index;// 获取目标目录项的锚点链接 href 属性值const targetItem = document.querySelector(`.table-of-contents a[href="#section-${index + 1}"]`) as HTMLElement;// 滚动目录以确保当前点击的目录项可见if (targetItem) {const container = document.querySelector(".table-of-contents") as HTMLElement;const containerRect = container.getBoundingClientRect();const scrollTop = targetItem.offsetTop - containerRect.height / 2;container.scrollTop = scrollTop;}
};// 生成目录
const generateTableOfContents = () => {const headings = document.querySelectorAll(".editor h1, .editor h2, .editor h3");const toc = [];headings.forEach((heading, index) => {const id = `section-${index + 1}`;const level = heading.tagName === "H1" ? 1 : heading.tagName === "H2" ? 2 : 3; // 根据标题等级设置目录项的缩进heading.setAttribute("id", id); // 设置标题的id属性toc.push({id: id, text: heading.textContent, level: level, index: index}); // 将标题文本、id和等级添加到目录项中});return toc;
};const handleScroll = () => {requestAnimationFrame(() => {const sections = document.querySelectorAll(".editor h1, .editor h2, .editor h3");const scrollY = window.scrollY || window.pageYOffset;let currentIndex = 0;for (let i = 0; i < sections.length; i++) {const sectionTop = (sections[i] as HTMLElement).offsetTop;if (scrollY >= sectionTop) {currentIndex = i;}}// 检查当前视图中是否有标题元素,如果有,将其索引赋给 currentIndexconst visibleSections = Array.from(sections).filter((section) => {const sectionTop = (section as HTMLElement).offsetTop;const sectionBottom = sectionTop + (section as HTMLElement).offsetHeight;return scrollY >= sectionTop && scrollY <= sectionBottom;});if (visibleSections.length > 0) {currentIndex = Array.from(sections).indexOf(visibleSections[visibleSections.length - 1]);}activeIndex.value = currentIndex;// 滚动目录以确保当前高亮的目录项可见const activeItem = document.querySelector(".table-of-contents .active") as HTMLElement;if (activeItem) {const container = document.querySelector(".table-of-contents");const containerRect = container.getBoundingClientRect();const activeRect = activeItem.getBoundingClientRect();const scrollTop = activeItem.offsetTop - containerRect.height / 2 + activeRect.height / 2;container.scrollTop = scrollTop;}});
};// 在编辑器创建后添加复制按钮
onMounted(() => {tableOfContents.value = generateTableOfContents();addAnchorLinks();copyCode();window.addEventListener("scroll", handleScroll);
});// 在组件销毁时移除滚动事件监听器
onBeforeUnmount(() => {window.removeEventListener("scroll", handleScroll);
});// 当前高亮的目录项索引
const activeIndex = ref(0);
</script><style src="@wangeditor/editor/dist/css/style.css"></style>
<style lang="scss" scoped>
@import "@/assets/styles/variables.module";html {scroll-behavior: smooth;
}.editor {overflow: hidden;min-height: 250px;z-index: 999;
}@media screen and (max-width: 900px) {.flex-container {display: none !important; /* 添加 !important 以确保覆盖其他样式 */}
}:deep() {.w-e-text-container {overflow: hidden;width: 100%;color: $base-text-color;border-bottom-right-radius: 8px;border-bottom-left-radius: 8px;background-color: $base-bg-box;.w-e-scroll {width: 100%;overflow: hidden !important;}[data-slate-editor] code {position: relative;background-color: $base-code-color;span {background-color: $base-code-color;}}pre > code {background-color: $base-code-color;// 防止花眼text-shadow: none;}iframe {width: 80%;height: 640px;display: block;border-radius: 8px;margin: 0 auto; /* 让图片水平居中 */}p {text-align: left; /* 保持文字左对齐 */line-height: 1.5rem;font-size: 0.875rem;font-family: "PingFang SC", sans-serif;}img {display: block;margin: 0 auto; /* 让图片水平居中 */max-width: 80%;//max-width: 80vw !important; /* 设置图片最大宽度为父元素宽度 */height: auto; /* 保持宽高比 */transform: scale(0.8); /* 设置缩放比例,这里是缩小为原来的80% */}[data-slate-editor] blockquote {background-color: $base-code-color;color: $base-text-color;border-radius: 2px;}[data-slate-editor] pre .copy-button {position: absolute;top: 6px;right: 6px;color: $base-text-color;border: none;padding: 0 5px;border-radius: 4px;cursor: pointer;}}.table-of-title {background-color: $base-bg-box;}.table-of-contents {color: $base-text-color;background-color: $base-bg-box;li:hover {color: #95c92c;}.active {font-weight: bold;color: #95c92c;}}
}
</style>
export const copy = (text: string, message: string, showSuccess: boolean = true) => {navigator.clipboard.writeText(text).then(function () {if (showSuccess) {ElMessage({message: message, type: 'success', duration: 1500});}}).catch(function (err) {console.error('Unable to copy text to clipboard', err);});
}
实现功能如图:
目录功能:
代码块复制功能:
具体实现效果可在平台https://web.yujky.cn/登录体验
租户:体验租户
用户名:cxks
密码: cxks123