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