ToB企服应用市场:ToB评测及商务社交产业平台

标题: wangeditor编辑器自定义按钮和节点,上传word转换html,文本更换 [打印本页]

作者: 种地    时间: 2024-8-26 14:13
标题: wangeditor编辑器自定义按钮和节点,上传word转换html,文本更换
vue3+ts
需求:在编辑器插入图片和视频时下方会有一个输入框填写描述,上传word功能
wangeditor文档wangEditor开源 Web 富文本编辑器,开箱即用,配置简朴
https://www.wangeditor.com/
 安装:npm install @wangeditor/editor --save
1、自定义按钮部分 index.ts,参考了文档
  1. import type { IButtonMenu, IDomEditor } from "@wangeditor/editor-for-vue";
  2. import { Range } from "slate";
  3. import { DomEditor } from "@wangeditor/editor";
  4. class VideoMenu implements IButtonMenu {
  5.   title: string;
  6.   tag: string;
  7.   iconSvg: string;
  8.   constructor() {
  9.     this.title = "上传视频";
  10.     this.iconSvg =
  11.       '<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>';
  12.     this.tag = "button";
  13.   }
  14.   getValue() {
  15.     return " ";
  16.   }
  17.   isActive() {
  18.     return false;
  19.   }
  20.   isDisabled(editor: IDomEditor): boolean {
  21.     //这部分参考的源码写的
  22.     const { selection } = editor;
  23.     if (selection == null) return true;
  24.     if (!Range.isCollapsed(selection)) return true; // 选区非折叠,禁用
  25.     const selectedElems = DomEditor.getSelectedElems(editor);
  26.     const hasVoidOrPre = selectedElems.some(elem => {
  27.       const type = DomEditor.getNodeType(elem);
  28.       if (type === "pre") return true;
  29.       if (type === "list-item") return true;
  30.       if (editor.isVoid(elem)) return true;
  31.       return false;
  32.     });
  33.     if (hasVoidOrPre) return true; // void 或 pre ,禁用
  34.     return false;
  35.   }
  36.   exec(editor: IDomEditor) {
  37.     if (this.isDisabled(editor)) return;
  38.     //点击打开上传视频的弹框
  39.     editor.emit("uploadvideo");
  40.   }
  41. }
  42. class TextReplace implements IButtonMenu {
  43.   title: string;
  44.   iconSvg: string;
  45.   tag: string;
  46.   constructor() {
  47.     this.title = "文本替换";
  48.     this.iconSvg =
  49.       '<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>';
  50.     this.tag = "button";
  51.   }
  52.   getValue() {
  53.     return false;
  54.   }
  55.   isActive() {
  56.     return false;
  57.   }
  58.   isDisabled(editor: IDomEditor): boolean {
  59.     const { selection } = editor;
  60.     if (selection == null) return true;
  61.     return false;
  62.   }
  63.   exec(editor: IDomEditor) {
  64.     if (this.isDisabled(editor)) return;
  65.     editor.emit("toggleModal", "textReplace", true);
  66.   }
  67. }
  68. class sendwordMenu implements IButtonMenu {
  69.   title: string;
  70.   tag: string;
  71.   constructor() {
  72.     this.title = "上传word";
  73.     this.tag = "button";
  74.   }
  75.   getValue() {
  76.     return " ";
  77.   }
  78.   isActive() {
  79.     return false;
  80.   }
  81.   isDisabled(editor: IDomEditor): boolean {
  82.     const { selection } = editor;
  83.     if (selection == null) return true;
  84.     if (!Range.isCollapsed(selection)) return true; // 选区非折叠,禁用
  85.     const selectedElems = DomEditor.getSelectedElems(editor);
  86.     const hasVoidOrPre = selectedElems.some(elem => {
  87.       const type = DomEditor.getNodeType(elem);
  88.       if (type === "pre") return true;
  89.       if (type === "list-item") return true;
  90.       if (editor.isVoid(elem)) return true;
  91.       return false;
  92.     });
  93.     if (hasVoidOrPre) return true; // void 或 pre ,禁用
  94.   }
  95.   exec(editor: IDomEditor) {
  96.     if (this.isDisabled(editor)) return;
  97.     //这里写点击按钮后的操作,我这里是调自定义事件
  98.     editor.emit("uploadword");
  99.   }
  100. }
  101. export const menu1Conf = {
  102.   key: "videomenu", // 定义 menu key :要保证唯一、不重复(重要)
  103.   factory() {
  104.     return new VideoMenu();
  105.   }
  106. };
  107. export const menu2Conf = {
  108.   key: "wordmenu",
  109.   factory() {
  110.     return new sendwordMenu();
  111.   }
  112. };
  113. export const menu3Conf = {
  114.   key: "textReplace",
  115.   factory() {
  116.     return new TextReplace();
  117.   }
  118. };
复制代码
 2、editorComponents.vue代码,在editor组件中引入index.ts和renderviedoEle/index和renderimgEle/index 
  1. <script setup lang="ts">
  2. import {
  3.   onBeforeUnmount,
  4.   ref,
  5.   reactive,
  6.   shallowRef,
  7.   defineEmits,
  8.   defineProps,
  9. } from "vue";
  10. import "@wangeditor/editor/dist/css/style.css";
  11. import {
  12.   Editor,
  13.   Toolbar,
  14.   IDomEditor,
  15. } from "@wangeditor/editor-for-vue";
  16. import {
  17.   Boot,
  18.   DomEditor,
  19. } from "@wangeditor/editor";
  20. import type { UploadInstance } from "element-plus";
  21. import mammoth from "mammoth";
  22. import customvideo from "@/utils/renderviedoEle/index";
  23. import customimage from "@/utils/renderimgEle/index";
  24. import {
  25.   menu1Conf,
  26.   menu2Conf,
  27.   menu3Conf,
  28. } from "@/utils/menus/index";
  29. defineOptions({
  30.   name: "editUpload"
  31. });
  32. const emit = defineEmits([
  33.   "changevalue",
  34. ]);
  35. const mode = "default";
  36. const props = defineProps({
  37.   editvalue: {
  38.     type: String,
  39.     default: ""
  40.   },
  41. });
  42. const localeditvalue = ref(props.editvalue);
  43. const txtplace = reactive({
  44.   findContent: "",
  45.   replaceContent: ""
  46. });
  47. const textReplaceShow = ref(false);
  48. const replaceTextInHTML = function (html, searchText, replaceText) {
  49.   // 定义全局匹配的正则表达式,匹配除了HTML标签之外的所有内容
  50.   const regex = />([^<]*)</g;
  51.   // 使用replace方法替换匹配到的文本内容
  52.   const replacedHtml = html.replace(regex, (match, text) => {
  53.     // 判断文本内容是否包含需要替换的搜索文本
  54.     if (text.includes(searchText)) {
  55.       // 替换文本内容
  56.       const replacedText = text.replace(
  57.         new RegExp(searchText, "g"),
  58.         replaceText
  59.       );
  60.       return `>${replacedText}<`;
  61.     } else {
  62.       // 不需要替换,返回原内容
  63.       return match;
  64.     }
  65.   });
  66.   return replacedHtml;
  67. };
  68. const handleSubmit = () => {//替换文本提交
  69.   const html = editorRef.value.getHtml();
  70.   const newHtml = replaceTextInHTML(
  71.     html,
  72.     txtplace.findContent,
  73.     txtplace.replaceContent
  74.   );
  75.   editorRef.value.setHtml(newHtml);
  76. };
  77. const insertVideo = val => {//插入视频
  78.   editorRef.value.restoreSelection();// 恢复选区
  79.   setTimeout(() => {
  80.     editorRef.value.insertNode({
  81.       type: "customvideo",
  82.       src: val.videoUrl,
  83.       poster: val.coverUrl,
  84.       videoId: val.videoID,
  85.       altDes: "",
  86.       children: [
  87.         {
  88.           text: ""
  89.         }
  90.       ]
  91.     });
  92.   }, 500);
  93. }
  94. const sendeluploads = ref<UploadInstance>();
  95. // 编辑器实例,必须用 shallowRef
  96. const editorRef = shallowRef();
  97. const toolbarConfig: any = {//这里把不想要的菜单排除掉
  98.   excludeKeys: [
  99.     "insertImage",
  100.     "insertVideo",
  101.     "uploadVideo",
  102.     "editvideomenu",
  103.     "group-video"
  104.   ]
  105. };
  106. const editorConfig = {
  107.   placeholder: "请输入内容...",
  108.   MENU_CONF: {}
  109. };
  110. // 在工具栏插入自定义的按钮
  111. toolbarConfig.insertKeys = {
  112.   index: 19, // 插入的位置,基于当前的 toolbarKeys
  113.   keys: [
  114.     "videomenu",
  115.     "wordmenu",
  116.     "textReplace"
  117.   ]
  118. };
  119. //注意:这个要再外面注入,不然会报错
  120. Boot.registerModule(customvideo);
  121. Boot.registerModule(customimage);
  122. const handleCreated = (editor: IDomEditor) => {
  123.   editorRef.value = editor;
  124.   // 判断已插入过就不要重复插入按钮
  125.   if (
  126.     !editor
  127.       .getAllMenuKeys()
  128.       ?.includes(
  129.         "videomenu",
  130.         "wordmenu",
  131.         "textReplace"
  132.       )
  133.   ) {
  134.     Boot.registerMenu(menu1Conf);
  135.     Boot.registerMenu(menu2Conf);
  136.     Boot.registerMenu(menu3Conf);
  137.   }
  138.   editor.on("uploadvideo", val => {
  139.       // 处理上传视频的逻辑,上传完直接插入视频 insertVideo()
  140.       // ........
  141.   });
  142.   editor.on("uploadword", () => {
  143.     // 点击上传word按钮模拟上传事件clik
  144.     sendeluploads.value.$.vnode.el.querySelector("input").click();
  145.   });
  146.   editor.on("toggleModal", (modalName, show) => {
  147.     // 显示替换的弹框
  148.     textReplaceShow.value = show;
  149.   });
  150. };
  151. const onChange = editor => {//编辑器的值改变
  152.   emit("changevalue", editor.getHtml());
  153. };
  154. // 组件销毁时,也及时销毁编辑器
  155. onBeforeUnmount(() => {
  156.   const editor = editorRef.value;
  157.   if (editor == null) return;
  158.   editor.destroy();
  159. });
  160. // 图片上传阿里云服务器
  161. editorConfig.MENU_CONF["uploadImage"] = {
  162.   // 自定义上传
  163.   async customUpload(file: File, insertFn) {
  164.    aliyunApi(file).then((res: any) => {
  165.       // 上传到服务器后插入自定义图片节点
  166.       editorRef.value.insertNode({
  167.         type: "customimage",
  168.         src: res.url,
  169.         alt: res.name,
  170.         href: res.url,
  171.         children: [
  172.           {
  173.             text: ""
  174.           }
  175.         ]
  176.       });
  177.     });
  178.   }
  179. };
  180. const handleSuccess = val => {};
  181. const beforeUpload = val => {};
  182. const handleUpload = val => {//上传完word文档后的处理,此处用到了mammoth.js,查看地址:https://github.com/mwilliamson/mammoth.js
  183.   // word文档转换插入到富文本
  184.   const file = val.file;
  185.   var reader = new FileReader();
  186.   reader.onload = function (loadEvent) {
  187.     var arrayBuffer = loadEvent.target?.result;
  188.     mammoth
  189.       .convertToHtml(
  190.         { arrayBuffer: arrayBuffer as ArrayBuffer },
  191.         { convertImage: convertImage }//将base64图片转换上传到阿里云服务器
  192.       )
  193.       .then(
  194.         function (result) {
  195.           // 没能修改插入图片的源码,这里自己做了下修改,加了customimage的div,让图片渲染走自己定义的节点
  196.           // 如果没有这一步,会默认插入原先img的那个节点
  197.           const parser = new DOMParser();
  198.           const doc = parser.parseFromString(result.value, "text/html");
  199.           const images = doc.getElementsByTagName("img");
  200.           for (let i = images.length - 1; i >= 0; i--) {
  201.             const img = images[i];
  202.             const div = doc.createElement("div");
  203.             div.setAttribute("data-w-e-type", "customimage");
  204.             div.setAttribute("data-w-e-is-void", "");
  205.             div.setAttribute("data-w-e-is-inline", "");
  206.             if (img.parentNode) {
  207.               img.parentNode.replaceChild(div, img);
  208.             }
  209.             div.appendChild(img);
  210.           }
  211.           const processedHtml = doc.body.innerHTML;
  212.           editorRef.value.dangerouslyInsertHtml(processedHtml);
  213.         },
  214.         function (error) {
  215.           console.error(error);
  216.         }
  217.       );
  218.   };
  219.   reader.readAsArrayBuffer(file);
  220. };
  221. // word图片转换
  222. const convertImage = mammoth.images.imgElement(image => {
  223.   return image.read("base64").then(async imageBuffer => {
  224.     const result = await uploadBase64Image(imageBuffer, image.contentType);
  225.     return { src: result };
  226.   });
  227. });
  228. const uploadBase64Image = async (base64Image, mime) => {
  229.   const _file = base64ToBlob(base64Image, mime);
  230.   let data: any = await aliyunApi(_file);
  231.   return data.url;
  232. };
  233. const base64ToBlob = (base64, mime) => {
  234.   mime = mime || "";
  235.   const sliceSize = 1024;
  236.   const byteChars = window.atob(base64);
  237.   const byteArrays = [];
  238.   for (
  239.     let offset = 0, len = byteChars.length;
  240.     offset < len;
  241.     offset += sliceSize
  242.   ) {
  243.     const slice = byteChars.slice(offset, offset + sliceSize);
  244.     const byteNumbers = new Array(slice.length);
  245.     for (let i = 0; i < slice.length; i++) {
  246.       byteNumbers[i] = slice.charCodeAt(i);
  247.     }
  248.     const byteArray = new Uint8Array(byteNumbers);
  249.     byteArrays.push(byteArray);
  250.   }
  251.   return new Blob(byteArrays, { type: mime });
  252. };
  253. </script>
  254. <template>
  255.   <div
  256.     class="wangeditor"
  257.   >
  258.     <Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" />
  259.     <Editor
  260.       id="editor-container"
  261.       v-model="localeditvalue"
  262.       :defaultConfig="editorConfig"
  263.       :mode="mode"
  264.       style="height: 500px; overflow-y: hidden; border: 1px solid #ccc"
  265.       @onCreated="handleCreated"
  266.       @onChange="onChange"
  267.     />
  268.     <el-upload
  269.       v-show="false"
  270.       ref="sendeluploads"
  271.       action="#"
  272.       :show-file-list="false"
  273.       accept=".docx"
  274.       :on-success="handleSuccess"
  275.       :before-upload="beforeUpload"
  276.       :http-request="handleUpload"
  277.     />
  278.     <el-dialog
  279.       v-model="textReplaceShow"
  280.       title="文本替换"
  281.       width="30%"
  282.       class="replacedialog"
  283.     >
  284.       <el-form
  285.         v-model="txtplace"
  286.         label-width="auto"
  287.       >
  288.         <el-form-item label="查找文本">
  289.           <el-input v-model="txtplace.findContent" />
  290.         </el-form-item>
  291.         <el-form-item label="替换文本">
  292.           <el-input v-model="txtplace.replaceContent" />
  293.         </el-form-item>
  294.         <el-form-item>
  295.           <el-button type="primary" @click="handleSubmit">替换</el-button>
  296.         </el-form-item>
  297.       </el-form>
  298.     </el-dialog>
  299.   </div>
  300. </template>
  301. <style scoped lang="scss">
  302. .replacedialog {
  303.   .el-form {
  304.     .el-form-item {
  305.       margin-bottom: 20px;
  306.       label {
  307.         font-weight: bold;
  308.         color: #333;
  309.       }
  310.       .el-input {
  311.         input {
  312.           color: #333;
  313.         }
  314.       }
  315.     }
  316.   }
  317. }
  318. </style>
  319. <style lang="scss">
  320. .w-e-image-container {
  321.   border: 2px solid transparent;
  322. }
  323. .w-e-text-container [data-slate-editor] .w-e-selected-image-container {
  324.   border: 2px solid rgb(180 213 255);
  325. }
  326. .w-e-text-container [data-slate-editor] img {
  327.   display: block !important;
  328.   margin: 0 auto;
  329. }
  330. .w-e-text-container [data-slate-editor] .w-e-image-container {
  331.   display: block;
  332. }
  333. .w-e-text-container [data-slate-editor] .w-e-image-container:hover {
  334.   box-shadow: none;
  335. }
  336. .txt-input {
  337.   .el-textarea__inner {
  338.     height: 300px;
  339.   }
  340. }
  341. .w-e-text-container [data-slate-editor] p {
  342.   margin: 5px 0;
  343. }
  344. .w-e-textarea-video-container video {
  345.   width: 30%;
  346. }
  347. .w-e-textarea-video-container {
  348.   background: none;
  349. }
  350. .w-e-text-container
  351.   [data-slate-editor]
  352.   .w-e-selected-image-container
  353.   .left-top {
  354.   display: none;
  355. }
  356. .w-e-text-container
  357.   [data-slate-editor]
  358.   .w-e-selected-image-container
  359.   .right-top {
  360.   display: none;
  361. }
  362. .w-e-text-container
  363.   [data-slate-editor]
  364.   .w-e-selected-image-container
  365.   .left-bottom {
  366.   display: none;
  367. }
  368. .w-e-text-container
  369.   [data-slate-editor]
  370.   .w-e-selected-image-container
  371.   .right-bottom {
  372.   display: none;
  373. }
  374. </style>
复制代码
 3、在页面中引用editor组件
  1. <script setup lang="ts">
  2. import { ref, reactive } from "vue";
  3. import { EdtiorUpload } from "@/components/editor";
  4. const editorcontent = ref("");
  5. const childeditRef = ref(null);
  6. const editorChange = val => {
  7.   // 编辑器值改变了...
  8. };
  9. </script>
  10. <template>
  11.   <div>
  12.     <div style="width: 100%">
  13.       <!-- 这里组件写ref标识 保证每次组件打开都能更新 -->
  14.       <EdtiorUpload
  15.         ref="childeditRef"
  16.         :editvalue="editorcontent"
  17.         @changevalue="editorChange"
  18.       />
  19.     </div>
  20.   </div>
  21. </template>
复制代码
4.自定义节点的部分renderviedoEle/index,renderimgEle/index 放在了githubhttps://github.com/srttina/wangeditor-customsalte/tree/master

 

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4