vue3+ts
需求:在编辑器插入图片和视频时下方会有一个输入框填写描述,上传word功能
wangeditor文档wangEditor开源 Web 富文本编辑器,开箱即用,配置简单https://www.wangeditor.com/
安装:npm install @wangeditor/editor --save
1、自定义按钮部分 index.ts,参考了文档
import type { IButtonMenu, IDomEditor } from "@wangeditor/editor-for-vue";
import { Range } from "slate";
import { DomEditor } from "@wangeditor/editor";class VideoMenu implements IButtonMenu {title: string;tag: string;iconSvg: string;constructor() {this.title = "上传视频";this.iconSvg ='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="black" d="M981.184 160.096C837.568 139.456 678.848 128 512 128S186.432 139.456 42.816 160.096C15.296 267.808 0 386.848 0 512s15.264 244.16 42.816 351.904C186.464 884.544 345.152 896 512 896s325.568-11.456 469.184-32.096C1008.704 756.192 1024 637.152 1024 512s-15.264-244.16-42.816-351.904zM384 704V320l320 192-320 192z"/></svg>';this.tag = "button";}getValue() {return " ";}isActive() {return false;}isDisabled(editor: IDomEditor): boolean {//这部分参考的源码写的const { selection } = editor;if (selection == null) return true;if (!Range.isCollapsed(selection)) return true; // 选区非折叠,禁用const selectedElems = DomEditor.getSelectedElems(editor);const hasVoidOrPre = selectedElems.some(elem => {const type = DomEditor.getNodeType(elem);if (type === "pre") return true;if (type === "list-item") return true;if (editor.isVoid(elem)) return true;return false;});if (hasVoidOrPre) return true; // void 或 pre ,禁用return false;}exec(editor: IDomEditor) {if (this.isDisabled(editor)) return;//点击打开上传视频的弹框editor.emit("uploadvideo");}
}
class TextReplace implements IButtonMenu {title: string;iconSvg: string;tag: string;constructor() {this.title = "文本替换";this.iconSvg ='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path fill="black" d="M11 6c1.38 0 2.63.56 3.54 1.46L12 10h6V4l-2.05 2.05A6.976 6.976 0 0 0 11 4c-3.53 0-6.43 2.61-6.92 6H6.1A5 5 0 0 1 11 6m5.64 9.14A6.89 6.89 0 0 0 17.92 12H15.9a5 5 0 0 1-4.9 4c-1.38 0-2.63-.56-3.54-1.46L10 12H4v6l2.05-2.05A6.976 6.976 0 0 0 11 18c1.55 0 2.98-.51 4.14-1.36L20 21.49L21.49 20z"/></svg>';this.tag = "button";}getValue() {return false;}isActive() {return false;}isDisabled(editor: IDomEditor): boolean {const { selection } = editor;if (selection == null) return true;return false;}exec(editor: IDomEditor) {if (this.isDisabled(editor)) return;editor.emit("toggleModal", "textReplace", true);}
}class sendwordMenu implements IButtonMenu {title: string;tag: string;constructor() {this.title = "上传word";this.tag = "button";}getValue() {return " ";}isActive() {return false;}isDisabled(editor: IDomEditor): boolean {const { selection } = editor;if (selection == null) return true;if (!Range.isCollapsed(selection)) return true; // 选区非折叠,禁用const selectedElems = DomEditor.getSelectedElems(editor);const hasVoidOrPre = selectedElems.some(elem => {const type = DomEditor.getNodeType(elem);if (type === "pre") return true;if (type === "list-item") return true;if (editor.isVoid(elem)) return true;return false;});if (hasVoidOrPre) return true; // void 或 pre ,禁用}exec(editor: IDomEditor) {if (this.isDisabled(editor)) return;//这里写点击按钮后的操作,我这里是调自定义事件editor.emit("uploadword");}
}
export const menu1Conf = {key: "videomenu", // 定义 menu key :要保证唯一、不重复(重要)factory() {return new VideoMenu();}
};export const menu2Conf = {key: "wordmenu",factory() {return new sendwordMenu();}
};
export const menu3Conf = {key: "textReplace",factory() {return new TextReplace();}
};
2、editorComponents.vue代码,在editor组件中引入index.ts和renderviedoEle/index和renderimgEle/index
<script setup lang="ts">
import {onBeforeUnmount,ref,reactive,shallowRef,defineEmits,defineProps,
} from "vue";
import "@wangeditor/editor/dist/css/style.css";
import {Editor,Toolbar,IDomEditor,
} from "@wangeditor/editor-for-vue";
import {Boot,DomEditor,
} from "@wangeditor/editor";
import type { UploadInstance } from "element-plus";
import mammoth from "mammoth";
import customvideo from "@/utils/renderviedoEle/index";
import customimage from "@/utils/renderimgEle/index";
import {menu1Conf,menu2Conf,menu3Conf,
} from "@/utils/menus/index";
defineOptions({name: "editUpload"
});
const emit = defineEmits(["changevalue",
]);const mode = "default";
const props = defineProps({editvalue: {type: String,default: ""},
});
const localeditvalue = ref(props.editvalue);
const txtplace = reactive({findContent: "",replaceContent: ""
});
const textReplaceShow = ref(false);const replaceTextInHTML = function (html, searchText, replaceText) {// 定义全局匹配的正则表达式,匹配除了HTML标签之外的所有内容const regex = />([^<]*)</g;// 使用replace方法替换匹配到的文本内容const replacedHtml = html.replace(regex, (match, text) => {// 判断文本内容是否包含需要替换的搜索文本if (text.includes(searchText)) {// 替换文本内容const replacedText = text.replace(new RegExp(searchText, "g"),replaceText);return `>${replacedText}<`;} else {// 不需要替换,返回原内容return match;}});return replacedHtml;
};
const handleSubmit = () => {//替换文本提交const html = editorRef.value.getHtml();const newHtml = replaceTextInHTML(html,txtplace.findContent,txtplace.replaceContent);editorRef.value.setHtml(newHtml);
};const insertVideo = val => {//插入视频editorRef.value.restoreSelection();// 恢复选区setTimeout(() => {editorRef.value.insertNode({type: "customvideo",src: val.videoUrl,poster: val.coverUrl,videoId: val.videoID,altDes: "",children: [{text: ""}]});}, 500);
}
const sendeluploads = ref<UploadInstance>();
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef();
const toolbarConfig: any = {//这里把不想要的菜单排除掉excludeKeys: ["insertImage","insertVideo","uploadVideo","editvideomenu","group-video"]
};
const editorConfig = {placeholder: "请输入内容...",MENU_CONF: {}
};
// 在工具栏插入自定义的按钮
toolbarConfig.insertKeys = {index: 19, // 插入的位置,基于当前的 toolbarKeyskeys: ["videomenu","wordmenu","textReplace"]
};//注意:这个要再外面注入,不然会报错
Boot.registerModule(customvideo);
Boot.registerModule(customimage);
const handleCreated = (editor: IDomEditor) => {editorRef.value = editor;// 判断已插入过就不要重复插入按钮if (!editor.getAllMenuKeys()?.includes("videomenu","wordmenu","textReplace")) {Boot.registerMenu(menu1Conf);Boot.registerMenu(menu2Conf);Boot.registerMenu(menu3Conf);}editor.on("uploadvideo", val => {// 处理上传视频的逻辑,上传完直接插入视频 insertVideo()// ........});editor.on("uploadword", () => {// 点击上传word按钮模拟上传事件cliksendeluploads.value.$.vnode.el.querySelector("input").click();});editor.on("toggleModal", (modalName, show) => {// 显示替换的弹框textReplaceShow.value = show;});};
const onChange = editor => {//编辑器的值改变emit("changevalue", editor.getHtml());
};
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {const editor = editorRef.value;if (editor == null) return;editor.destroy();
});// 图片上传阿里云服务器
editorConfig.MENU_CONF["uploadImage"] = {// 自定义上传async customUpload(file: File, insertFn) {aliyunApi(file).then((res: any) => {// 上传到服务器后插入自定义图片节点editorRef.value.insertNode({type: "customimage",src: res.url,alt: res.name,href: res.url,children: [{text: ""}]});});}
};const handleSuccess = val => {};
const beforeUpload = val => {};
const handleUpload = val => {//上传完word文档后的处理,此处用到了mammoth.js,查看地址:https://github.com/mwilliamson/mammoth.js// word文档转换插入到富文本const file = val.file;var reader = new FileReader();reader.onload = function (loadEvent) {var arrayBuffer = loadEvent.target?.result;mammoth.convertToHtml({ arrayBuffer: arrayBuffer as ArrayBuffer },{ convertImage: convertImage }//将base64图片转换上传到阿里云服务器).then(function (result) {// 没能修改插入图片的源码,这里自己做了下修改,加了customimage的div,让图片渲染走自己定义的节点// 如果没有这一步,会默认插入原先img的那个节点const parser = new DOMParser();const doc = parser.parseFromString(result.value, "text/html");const images = doc.getElementsByTagName("img");for (let i = images.length - 1; i >= 0; i--) {const img = images[i];const div = doc.createElement("div");div.setAttribute("data-w-e-type", "customimage");div.setAttribute("data-w-e-is-void", "");div.setAttribute("data-w-e-is-inline", "");if (img.parentNode) {img.parentNode.replaceChild(div, img);}div.appendChild(img);}const processedHtml = doc.body.innerHTML;editorRef.value.dangerouslyInsertHtml(processedHtml);},function (error) {console.error(error);});};reader.readAsArrayBuffer(file);
};// word图片转换
const convertImage = mammoth.images.imgElement(image => {return image.read("base64").then(async imageBuffer => {const result = await uploadBase64Image(imageBuffer, image.contentType);return { src: result };});
});const uploadBase64Image = async (base64Image, mime) => {const _file = base64ToBlob(base64Image, mime);let data: any = await aliyunApi(_file);return data.url;
};
const base64ToBlob = (base64, mime) => {mime = mime || "";const sliceSize = 1024;const byteChars = window.atob(base64);const byteArrays = [];for (let offset = 0, len = byteChars.length;offset < len;offset += sliceSize) {const slice = byteChars.slice(offset, offset + sliceSize);const byteNumbers = new Array(slice.length);for (let i = 0; i < slice.length; i++) {byteNumbers[i] = slice.charCodeAt(i);}const byteArray = new Uint8Array(byteNumbers);byteArrays.push(byteArray);}return new Blob(byteArrays, { type: mime });
};</script><template><divclass="wangeditor"><Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" /><Editorid="editor-container"v-model="localeditvalue":defaultConfig="editorConfig":mode="mode"style="height: 500px; overflow-y: hidden; border: 1px solid #ccc"@onCreated="handleCreated"@onChange="onChange"/><el-uploadv-show="false"ref="sendeluploads"action="#":show-file-list="false"accept=".docx":on-success="handleSuccess":before-upload="beforeUpload":http-request="handleUpload"/><el-dialogv-model="textReplaceShow"title="文本替换"width="30%"class="replacedialog"><el-formv-model="txtplace"label-width="auto"><el-form-item label="查找文本"><el-input v-model="txtplace.findContent" /></el-form-item><el-form-item label="替换文本"><el-input v-model="txtplace.replaceContent" /></el-form-item><el-form-item><el-button type="primary" @click="handleSubmit">替换</el-button></el-form-item></el-form></el-dialog></div>
</template>
<style scoped lang="scss">
.replacedialog {.el-form {.el-form-item {margin-bottom: 20px;label {font-weight: bold;color: #333;}.el-input {input {color: #333;}}}}
}
</style>
<style lang="scss">
.w-e-image-container {border: 2px solid transparent;
}.w-e-text-container [data-slate-editor] .w-e-selected-image-container {border: 2px solid rgb(180 213 255);
}.w-e-text-container [data-slate-editor] img {display: block !important;margin: 0 auto;
}.w-e-text-container [data-slate-editor] .w-e-image-container {display: block;
}.w-e-text-container [data-slate-editor] .w-e-image-container:hover {box-shadow: none;
}.txt-input {.el-textarea__inner {height: 300px;}
}.w-e-text-container [data-slate-editor] p {margin: 5px 0;
}.w-e-textarea-video-container video {width: 30%;
}.w-e-textarea-video-container {background: none;
}.w-e-text-container[data-slate-editor].w-e-selected-image-container.left-top {display: none;
}.w-e-text-container[data-slate-editor].w-e-selected-image-container.right-top {display: none;
}.w-e-text-container[data-slate-editor].w-e-selected-image-container.left-bottom {display: none;
}.w-e-text-container[data-slate-editor].w-e-selected-image-container.right-bottom {display: none;
}
</style>
3、在页面中引用editor组件
<script setup lang="ts">
import { ref, reactive } from "vue";
import { EdtiorUpload } from "@/components/editor";
const editorcontent = ref("");
const childeditRef = ref(null);
const editorChange = val => {// 编辑器值改变了...
};
</script><template><div><div style="width: 100%"><!-- 这里组件写ref标识 保证每次组件打开都能更新 --><EdtiorUploadref="childeditRef":editvalue="editorcontent"@changevalue="editorChange"/></div></div>
</template>
4.自定义节点的部分renderviedoEle/index,renderimgEle/index 放在了githubhttps://github.com/srttina/wangeditor-customsalte/tree/master