wangeditor编辑器自定义按钮和节点,上传word转换html,文本替换

vue3+ts

需求:在编辑器插入图片和视频时下方会有一个输入框填写描述,上传word功能

wangeditor文档wangEditor开源 Web 富文本编辑器,开箱即用,配置简单https://www.wangeditor.com/

安装:npm install @wangeditor/editor --save

1、自定义按钮部分 index.ts,参考了文档

TypeScript 复制代码
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

TypeScript 复制代码
<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, // 插入的位置,基于当前的 toolbarKeys
  keys: [
    "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按钮模拟上传事件clik
    sendeluploads.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>
  <div
    class="wangeditor"
  >
    <Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" />
    <Editor
      id="editor-container"
      v-model="localeditvalue"
      :defaultConfig="editorConfig"
      :mode="mode"
      style="height: 500px; overflow-y: hidden; border: 1px solid #ccc"
      @onCreated="handleCreated"
      @onChange="onChange"
    />
    <el-upload
      v-show="false"
      ref="sendeluploads"
      action="#"
      :show-file-list="false"
      accept=".docx"
      :on-success="handleSuccess"
      :before-upload="beforeUpload"
      :http-request="handleUpload"
    />
    <el-dialog
      v-model="textReplaceShow"
      title="文本替换"
      width="30%"
      class="replacedialog"
    >
      <el-form
        v-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组件

TypeScript 复制代码
<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标识 保证每次组件打开都能更新 -->
      <EdtiorUpload
        ref="childeditRef"
        :editvalue="editorcontent"
        @changevalue="editorChange"
      />
    </div>
  </div>
</template>

4.自定义节点的部分renderviedoEle/index,renderimgEle/index 放在了githubhttps://github.com/srttina/wangeditor-customsalte/tree/master

相关推荐
Mortal_hhh8 小时前
VScode的C/C++点击转到定义,不是跳转定义而是跳转声明怎么办?(内附详细做法)
ide·vscode·stm32·编辑器
电子云与长程纠缠17 小时前
UE5.3中通过编辑器工具创建大纲菜单文件夹
java·ue5·编辑器
lucky九年18 小时前
vscode翻译插件
ide·vscode·编辑器
真·Wild·攻城狮19 小时前
【码农日常】Vscode Clangd初始化失败(Win10)
ide·vscode·编辑器
七灵微19 小时前
【测试】【Debug】vscode中同一个测试用例出现重复
ide·vscode·编辑器
4U2471 天前
Linux入门之vim
linux·编辑器·vim·命令模式·底行模式
Liquor14192 天前
vim 编辑器
java·linux·c语言·开发语言·python·编辑器·vim
skywalk81632 天前
三周精通FastAPI:33 在编辑器中调试
python·编辑器·fastapi
188_djh3 天前
# vim那些事...... vim删除文件全部内容
linux·ubuntu·centos·编辑器·vim·vi·vim删除文件全部内容
虞书欣的63 天前
Python小游戏22——吃豆豆小游戏
python·算法·游戏·编辑器·pygame