一、背景
说到富文本,就像现在在使用的这个富文本一样。这个插入,就像点击工具栏,插入图片或者表格。
业务需求是做一个邮件模板,前端书写某些参数用作占位符,后端将占位符替换成具体的内容。比如:
其中, ${nameName}就是占位符。那业务上会给出一个参数列表,如 userName、loginUrl等等,在邮件模板中需要填写这些参数。
二、设计思路
需求的设计思路是,用户手动输入
占位符。在后续的功能里,当这个邮件模板确定之后,里面的占位符就不能编辑。
当用户手动输入一段文字,如 ${username},在编辑器中渲染的其实是 text ,我们很难对 text进行处理。
而且发现,当用户给这个文字加上颜色时,会自动使用一个 span 标签将其包住,那这样就出现了 text 和 html 两种类型,所以出于能处理的需要,我们强制将这些 占位符 转换成标签。
1. 第一种思路
当模板编辑结束,提交的时候,使用正则将形如 ${xxx} 替换成 span。
const regex = /\$\{(.*?)\}/g;
editorContent = editorContent.replace(regex, '<span class="highlight">${$1}</span>');
这样在提交到数据库的时候,占位符就是一个span 标签了,另外我们可以给 highlight 设置样式。在数据回显的时候,就可以看到 ${userName} 被包住,并有样式。
但是,在用户初次新增的时候,占位符仍是 text , 且看不到颜色,只要用户手动添加颜色才能看到。
另外在后续的调试中发现,如果给占位符添加颜色时,没有包住整个 ${xxxx},那占位符的内容格式将会被打乱。例如:
${xxx} 的内容已经被分割开,这样后端无法识别完整的 占位符,哪怕后端或者前端在提交时提示‘参数错误’,用户也不明白参数哪里有问题。
所以核心的一个点,就是不允许用户手动输入 占位符,这就是思路二。
2、第二种思路
插入内容,这就是这篇文章的核心了。我们不允许用户手动输入,或者说用户手动输入的东西不符合前端的规范时,在在前端页面上,就不处理处理。
因此,这就不是单纯的插入文本,也不是大众的插入图片,而是插入 标签 。 这里面涉及到 标签、光标,两个知识点。
在使用 vue2-editor 时,虽然是基于 quill 封装的,但并没有把 quill 的众多方法暴露出来,最基本的就很难获取到光标位置和设置光标位置与插入标签。因此,还是使用 vue-quill-editor 来做这个功能。
在业务上的实现是,点击参数列表中的参数时,就将该参数插入到编辑器光标位置。
在技术上的实现是,点击参数时,将参数替换成标签,然后插入到光标位置。
1. 参数预处理
先把参数处理成 ${xxxx}格式
const param = '${' + value + '}';
2. 获取光标位置
const selection = quill.selection.savedRange; //含有光标信息
let index = selection.index; //光标位置
3. 插入方法
vue-quill-editor 的插入方法有以下几种。
insertText
quill.insertText(0, 'Hello', 'bold', true);
这是文本的插入,不符合
insertEmbed
quill.insertEmbed(10, 'image', 'https://quilljs.com/images/cloud.png');
这个可以插入 对象,应该是可以插入 html, 待定
-dangerouslyPasteHTML
quill.clipboard.dangerouslyPasteHTML(5, ' <b>World</b>');
这个可以插入标签,待定。
三、占位符处理
经过上面的处理(第二种思路),已经是可以通过点击,向编辑器插入标签了,现在需要对这个标签进行处理,添加颜色,禁止编辑及提示等。
在实际操作中,使用dangerouslyPasteHTML并没有生效。
quill.clipboard.dangerouslyPasteHTML(index, `<span>${param}</span>`);
因此,转而使用 insertEmbed,其中使用到了 vue-quill-editor的自定义模块。
const Inline = Quill.import('blots/inline');
继承这个模块,自定义创建一个 行盒Node,并对这个 NODE 进行 dom 的操作。
import 'quill/dist/quill.snow.css'; // 引入 Quill
import Quill from 'quill';
const Inline = Quill.import('blots/inline');
class CustomSpanBlot extends Inline {
static create(value) {
const node = super.create();
node.innerHTML = value.text || '';
node.classList.add('my-span-class'); // 添加自定义类名
node.style.color = '#5968F6'; //添加颜色
node.setAttribute('contenteditable', false); //禁止编辑
return node;
}
static formats(node) {
return node.classList.contains('my-span-class') ? { 'custom-span': true } : undefined;
}
}
CustomSpanBlot.blotName = 'custom-span'; // 自定义 blot 名称
CustomSpanBlot.tagName = 'span'; // 指定元素标签名
Quill.register(CustomSpanBlot); // 注册自定义 blot
这样这个自定义类就ok了,现在使用这个自定义模块,就可以创建一个 span 标签了。
insertText(value) {
const quill = this.$refs.quillEditor.quill;
const selection = quill.selection.savedRange;
const param = '${' + value + '}';
if (selection) {
let index = selection.index;
quill.insertEmbed(index, 'custom-span', { text: param });
quill.setSelection(index + 1);
}
},
添加样式
.my-span-class {
cursor: not-allowed;
&:hover {
color: #c8c9cc !important;
}
}
到目前为止,已经可以实现基本功能了:点击参数,在光标处插入标签,并标签不可编辑。但是还有一些小毛病,如前后间距,焦点位置等。
- 前后间距
现在插入的标签是与前面的内容完全贴合在一起的,为了格式清晰,我们可以在标签前后各插入一个 空格 。
if (selection) {
let index = selection.index;
quill.insertText(index, ' ');
quill.insertEmbed(index + 1, 'custom-span', { text: param });
quill.insertText(index + param.length + 1, ' ');
quill.setSelection(index + param.length, 0); // 第二个参数设为 0 确保光标不消失
}
- 焦点位置
如果你走到了这个地方,你会发现这个焦点始终都不会出现。编辑器插入标签之后,编辑器就失去焦点了,无论怎么设置光标位置,又调用quill.focus()手动添加焦点,都不生效。
经过一步一步的检查,最终发现是 contenteditable属性导致的,只要把这个属性去掉,光标就出现了。但是,我们的目的是让 span 不可被编辑,仅此,不能去掉,这就走进了死胡同。
另外,当点击参数名称插入标签后(此时编辑器无显示光标),我们再次点击参数名称,就会无限插入 标签,这就导致 ${xxx} 的格式全乱了。
这都是没有获取到光标引起的。所以,这个两个问题还是很严重的,我们不得不换另一种方案。
四、更换自定义类
之前是使用blots/inline,现在使用 parchment,同样的配置。
import 'quill/dist/quill.snow.css'; // 引入 Quill
import Quill from 'quill';
const Parchment = Quill.import('parchment');
class MyCustomBlot extends Parchment.Embed {
static blotName = 'customTag';
static tagName = 'span';
static className = 'non-editable';
static create(value) {
let node = super.create(value);
node.setAttribute('data-value', value);
node.setAttribute('contenteditable', false);
node.style.color = '#5968F6';
node.innerText = value;
return node;
}
static value(node) {
return node.getAttribute('data-value');
}
}
Quill.register(MyCustomBlot);
insertText(value) {
const quill = this.$refs.quillEditor.quill;
const selection = quill.selection.savedRange;
const param = '${' + value + '}';
if (selection) {
let index = selection.index;
quill.insertText(index, ' '); //前插入空格
quill.insertEmbed(index + 1, 'customTag', param);
quill.insertText(index + 2, ' '); //后插入空格
quill.setSelection(index + 3); //光标位置
}
},
至此,这个功能在业务和逻辑都没问题了。
- 点击参数名称,在光标处插入标签
- 在标签前后添加空格
- 给标签设置样式、属性、类名
- 保证光标正常显示,插入的标签独立,无嵌套
- 标签不可编辑
五、适应邮箱
通过上面的处理后,占位符 不可编辑的功能已经没问题了,在vue-quill-editor里的内容能正确显示,在数据回显时,样式等等都能正常显示。
但是,通过观察可以发现,富文本里的 居中、缩进用的是外联样式来设置text-align和padding。这样一来,在收件人的网页邮箱里,那是没有这个类名的,这就导致了邮件模板的样式在收件人那里通通失效。因此,我们需要特别处理。
可以发现,vue-quill-editor在缩进一个单位时,添加了该类名
.ql-indent-1 {
padding-left: 3em;
}
仅此我们需要根据标签的类名,动态地添加上style内联样式。
addPaddingToClass(htmlString) {
return htmlString.replace(/(<[^>]*class="[^"]*ql-indent-(\d+)[^"]*"[^>]*)(>)([^<]*)(<\/[^>]*>)/gi, (match, p1, level, p3, p4, p5) => {
// 计算左内边距
const padding = parseInt(level, 10) * 3;
// 如果已经有 style 属性,则追加,否则添加新的 style 属性
if (p1.includes('style=')) {
return match.replace(/style="([^"]*)"/, `style="$1; padding-left: ${padding}px;"`);
} else {
return `${p1} style="padding-left: ${padding}em;"${p3}${p4}${p5}`;
}
});
},
同理,也要对文本对齐方式进行处理
addTextAlignStyles(htmlString) {
// 定义对齐方式的映射
const alignments = {
'ql-align-left': 'left',
'ql-align-center': 'center',
'ql-align-right': 'right',
'ql-align-justify': 'justify',
};
// 使用正则表达式为不同的类名添加 text-align 样式
return htmlString.replace(/(<[^>]*class="[^"]*?(ql-align-\w+)[^"]*"[^>]*)(>)([^<]*)(<\/[^>]*>)/gi, (match, p1, className, p3, p4, p5) => {
const alignment = alignments[className];
if (alignment) {
if (p1.includes('style=')) {
return match.replace(/style="([^"]*)"/, `style="$1 text-align: ${alignment};"`);
} else {
return `${p1} style="text-align: ${alignment};"${p3}${p4}${p5}`;
}
}
return match;
});
},
到此,邮件模板就基本完成了!