前言
个人觉得wangEditor是一款体验不错的富文本编辑器,还有详细的中文文档,算是非常友好了。只可惜编辑器自带的图片操作方式不是很对我们产品经理的胃口,不过好在编辑器可以自定义元素,扩展还是比较方便的,在此记录下。
首先编辑器自带的图片格式长这样,是通过悬浮的工具条来操作的,想要编辑备注必须要点开来才行
接下来是自定义的效果,参考了知乎的展示方式和交互
功能点
- 可以通过关闭按钮删除图片
- 可以通过按钮切换图片宽度为全宽或固定宽度
- 可以直接编辑备注
- 可以复制黏贴备注,备注超过字数限制时停止输入
- 上传图片时有loading效果
- 支持批量上传图片,失败重传
实现步骤
其实编辑器是提供了部分的自定义功能的,上传图片自定义 提供了insertFn函数,这个函数内部是以默认的图片节点格式{type:'image'}插入图片的。我们要做的就是跳过这个步骤,插入自定义的图片元素。
定义新节点的格式
因为要编辑备注,所以没有设计成void的形式,用children来放文本节点,具体格式如下
const thingImage = {
type: 'thingImage',
src: '',
file: null, // 需要上传的文件
mode: '', // 居中还是全宽
children: [{ text: '' }] // 用于放置备注
};
创建渲染函数
这里参考官方文档根据需要来写,几个关键节点写清楚就好。需要注意渲染函数每当节点更新时都会触发。
关闭按钮
通过slate的api进行删除操作,其中path表示图片节点的位置
const path = DomEditor.findPath(editor, elem); //查找节点的位置
const closeIconVnode = h(
'div',
{
props: { className: 'ImageDelete-Wrapper-icon' },
on: {
click() {
SlateTransforms.removeNodes(editor, { at: path }); //删除节点
editor.restoreSelection(); // 恢复选区
}
}
},
[]
);
切换宽度按钮
let isFullWidth = mode === 'full';
const buttonVnode = h(
'div',
{
props: { className: `Image-buttonWrap ${isFullWidth ? 'status-full' : 'status-middle'}` },
style: {},
on: {
click() {
SlateTransforms.setNodes(
editor,
{
mode: isFullWidth ? 'middle' : 'full'
},
{
at: path,
mode: 'highest' // 针对最高层级的节点
}
);
}
}
}
// [`切换为${isFullWidth ? '居中' : '全宽'}`] //这里不知道为啥切换中文输入会导致节点丢失,改用了css方案实现
);
切换是通过slate的api重新设置节点属性实现
另外,原本我想的是通过文本节点来显示按钮文本,但是不知道为什么切换输入法会导致文本消失,所以后面我就改用了css方案,通过切换类名+伪元素来显示文案。
备注节点
const placeholderText = '添加图片注释,不超过 140 字(可选)';
const emptyText = '';
...
...
const isDisabled = editor.isDisabled(); //编辑器是否禁用
//获取文本节点的位置
const textPath = isDisabled ? [] : DomEditor.findPath(editor, elem.children[0]);
...
...
const remarkVnode = h(
'div',
{
props: { className: 'Image-caption' },
style: {},
on: {
click() {
if (remark === placeholderText) {
SlateTransforms.insertText(editor, emptyText, { at: textPath }); //替换节点
}
}
}
},
children //将子节点插入,渲染函数中的参数children
);
备注是直接将子节点插入,设计上虽然子节点是个数组children,但是我们只将第一个节点的内容作为备注内容。如果是黏贴过来的文本会出现多个子节点,这一部分处理将放到插件中。
点击事件:如果还没有输入过备注则显示默认备注,当点击时如果文本内容还是跟默认备注一致,则表示要编辑备注,此时将备注设置成空字符串,这里的insertText字面上是插入节点,在同一个位置插入textPath,就会变成替换节点。
其他状态判断,选中,失去选中,超出字数限制的处理
const remark_limit = 140; //备注字数限制
const textPath = isDisabled ? [] : DomEditor.findPath(editor, elem.children[0]); //文本节点的位置
const selected = DomEditor.isNodeSelected(editor, elem); // 节点是否选中
let remark = elem.children[0].text;//获取备注内容
...
...
// 未选中/选中未添加备注时处理placeholder
if (!isDisabled) { //未禁用状态下才会对选中和不选中做出反应
if (!selected && !remark.trim()) { // 未选中并且没有备注,设置为默认备注
SlateTransforms.insertText(editor, placeholderText, { at: textPath }); //替换为默认文本
} else if (selected && remark === placeholderText) { //选中并且文本为默认文本
SlateTransforms.insertText(editor, emptyText, { at: textPath }); //替换为空文本
} else if (remark && remark.trim().length > remark_limit) {//超出字数限制时
SlateTransforms.insertText(editor, remark.trim().substring(0, remark_limit), { at: textPath });
}
}
定义插件
插件主要是通过重写api来增加一些节点的特殊情况处理,如插入节点标准化,回车的处理,黏贴文本的处理等。
节点标准化
这部分我是从源码中copy的,这个需求还是挺常见的。就是为了方便编辑需要在后面插入一个空的p
// 重写 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node);
if (type !== 'thingImage') {
// 未命中 thingImage ,执行默认的 normalizeNode
return normalizeNode([node, path]);
}
// editor 顶级 node
const topLevelNodes = newEditor.children || [];
// --------------------- thingImage 后面必须跟一个 p header blockquote(否则后面无法继续输入文字) ---------------------
const nextNode = topLevelNodes[path[0] + 1] || {};
const nextNodeType = DomEditor.getNodeType(nextNode);
if (nextNodeType !== 'paragraph' && nextNodeType !== 'blockquote' && !nextNodeType.startsWith('header')) {
// thingImage node 后面不是 p 或 header ,则插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] };
const insertPath = [path[0] + 1];
SlateTransforms.insertNodes(newEditor, p, {
at: insertPath // 在 link-card 后面插入
});
}
};
处理回车
因为前面已经处理了节点,也就是说图片节点后面至少有个空的p,所以回车只需要移动到下一个位置就行了。
newEditor.insertBreak = () => {
const selectedNode = DomEditor.getSelectedNodeByType(newEditor, 'thingImage');
if (selectedNode != null) {
// 选中了 thingImage ,则移动到下一行
const path = DomEditor.findPath(editor, selectedNode); //查找改节点在content中的位置
newEditor.select({
anchor: { path: [path[0] + 1, 0], offset: 0 },
focus: { path: [path[0] + 1, 0], offset: 0 }
});
return;
}
insertBreak();
};
黏贴文本
import { Editor } from 'slate';
// 重写 insertData - 粘贴文本
newEditor.insertData = data => {
const selectedNode = DomEditor.getSelectedNodeByType(newEditor, 'thingImage');
if (selectedNode == null) {
insertData(data); // 执行默认的 insertData
return;
}
// 获取文本,并插入到备注
const text = data.getData('text/plain');
Editor.insertText(newEditor, text);
};
上传图片Loading效果
要做loading,首先要一个占位图,将选择的文件转成base64插入到编辑器,并通过样式添加loading效果,等上传完毕后再更新节点的图片url。
前面提到编辑器提供了自定义上传的入口,而我们只是要跳过插入图片这部分并替换成插入我们的自定义节点,所以上传这部分还是用已经提供的配置来做。
然后再渲染函数中判断,如果有file,则添加loading类名
const waitUploadFile = !!file; //需要上传文件的情况
...
...
// 自定义图片元素 vnode
const thingCardVnode = h(
// HTML tag
'div',
// HTML 属性、样式、事件
{
props: {
className: `thing-page-image Image-captionContainer ${selected && !isDisabled ? 'thing-image-container' : ''} ${
waitUploadFile ? 'Image-loading' : ''
}`
}, // Image-loading就是loading类
},
// 子节点
isDisabled ? [imageWrapVnode] : [imageWrapVnode, remarkVnode]
);
上传完成的处理则是在插件中处理,重写insertNode,当上传完成时通过setNodes更新节点取消loading。除此之外,也可以做一些默认值的处理,如插入自定义图片节点时添加placeholder
//插件
// 重写 insertNode,插入节点前加入placeholder节点,上传图片
newEditor.insertNode = node => {
if (node.type === 'thingImage') {
if (!node.children) {
node.children = [{ text: placeholderText }];
}
if (node.file) {
upload(node.file).then(res => { //upload是自己写的上传方法
const path = DomEditor.findPath(editor, node); //查找改节点的位置
SlateTransforms.setNodes(
editor,
{
src: res.addr,//图片url
file: null
},
{
at: path,
mode: 'highest' // 针对最高层级的节点
}
);
});
}
let res = insertNode(node);
setTimeout(() => {
// 插入图片后,将光标移动到后面的位置,防止直接选中图片,强制编辑图片备注,这里没想到好的方式,暴力使用setTimeOut方案
let path = DomEditor.findPath(editor, node);
newEditor.select({
anchor: { path: [path[0] + 1, 0], offset: 0 },
focus: { path: [path[0] + 1, 0], offset: 0 }
});
}, 200);
return res;
}
return insertNode(node);
};
导出模块
参考官方文档导出为模块即可
const module = {
editorPlugin: withThingImage, // 插件,前文提到的插件方法都在这里
renderElems: [renderElemConf], // renderElem,前文提到的渲染函数处理都在这里
elemsToHtml: [elemToHtmlConf], // elemToHtml 参考官方文档
parseElemsHtml: [parseHtmlConf] // parseElemHtml 参考官方文档
};
export default module;
使用
import { Boot } from '@wangeditor/editor';
import thingImage from './module/thingImage';
Boot.registerModule(thingImage);
结尾语
因为也是第一次使用wangEditor,大部分都是参考官方文档和源码来解决问题的,整体认识还比较粗浅,以实现需求为第一优先级。有些地方实现的不一定对或者有更好的方式,欢迎大家讨论。