因需提供文章编辑功能,开始觉得这个富文本编辑器,应该是很成熟的功能,找个开源项目分分钟搞定。
但从一开始选型就有坑,而且越做发现要做的越多,遇到的问题也越多。
最初,觉得在外面看到的编辑器,大部分无数学公式编辑功能,所以首选就搜支持数学公式的编辑器,然后就fluent editor排在前面,被其介绍给打动了。
被它坑了那就吐槽下,搜索排行的真的很坑,也不知它是不是买了排行。当然也可能使用它的,都不需再次开发,或者找它做二次开发。
对于fluent editor,如果你需在它基础上再开发,理性劝退,它封装后,有些地方和Quill的写法一致,有的又不一致;用其他插件出现兼容问题,也不好排查是Quill本身问题还是它封装的问题。
虽然折腾fluent editor浪费了许多时间,但也算是熟悉了Quill吧。
后来,实在是遇到许多很纳闷的问题,就怀疑其封装后的问题。毕竟Quill在网上使用频率这么高,不至于我这基础功能也出问题吧?
所以就转战Vue Quill。因为折腾fluent editor许久,很快就搭建好了Vue Quill,然后就是丰富工具栏,但在Table插件时遇到问题,其提供的Vue代码拷贝过来,尽然报错,不知是否Vue版本问题(很讨厌框架各个版本间问题),看有纯HTML的例子,运行没有问题,也就把Quill也转到直接引用js。就在以为万事大吉之刻,图片Resize功能又不行,网上搜索都是Vue的模块,未提供纯HTML方式。哎!直接<script> npm中拷贝出js,失败!只好继续网上一通狂搜,终于找到一个提供<script>的功能。折腾十多天看到希望了。
现在总结富文本编辑器所需的功能:
从展现形式分:
- 文字、图片、视频:所有的富文本编辑器都会提供的基础功能;
- 附件:其实和图片的操作雷同,仅是展现形式需自定义Blot;
- 表格:网上找到quill-table-better和quill-better-table(取名难道就这么难么?非得这么纠缠),看了示例,quill-table-better在点击表格后,提供一个操作toolbar,体验更好。所以选择了quill-table-better;
- 数学公式:Quill本身提供的公式编辑框不适合复杂公式编辑,比如编辑框外观太小,误点击到编辑框外就隐藏等,需自己修改。
- 代码高亮:使用highlightjs
从功能完整性、使用体验上分:
- 缓存:不能编辑了一大段,误刷新或断电后丢失;
- 粘贴:粘贴至编辑器时,默认把剪贴板中内容原封不动的拷进来。实际剪贴板中的内容并不仅仅是我们选择的那些文字,背后会有很多其他内容。不同地方拷贝,在剪贴板中的内容格式不同。从网页中拷贝的,是HTML格式;从Word中拷贝,带有很多Word的样式内容;从IDE中拷贝的,每个IDE有自己独有的格式;遇到拷贝内容中包含图片的,需要下载下来上传至自有服务器(避免其他平台内容删除而影响显示);所以需要针对每种情况分别处理;
- 导出:提供导出为PDF、Word、Markdown格式。
图片、视频、附件都是需要上传到服务器,再显示在编辑器。fluent editor中提供了附件,但名称和大小显示总出问题。
刚开始不熟悉Quill,就本着原装会比较好的原则,和DeepSeek一起折腾了很久,最后放弃。用Quill的Blot重写一个:
const BlockEmbed = Quill.import('blots/block/embed')
class FileBlot extends BlockEmbed {
static create(fileInfo) {
const node = super.create()
node.innerHTML = `
<a class="ql-file-item yuyyy-sns-file" href="${fileInfo.url}">
<img src="images/icon/attach16.png" style="width:16px; height:16px; border: 0"/>
${fileInfo.name}${fileInfo.size ? '(' + fileInfo.size + ')' : ''}
</a>
`
node.setAttribute('data-file', JSON.stringify(fileInfo))
node.setAttribute('style', 'overflow: visible')
return node
}
static value(node) {
return JSON.parse(node.getAttribute('data-file'))
}
}
FileBlot.blotName = 'file'
FileBlot.tagName = 'span'
FileBlot.className = 'ql-file-item'
Quill.register(FileBlot, true)
表格:
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
Quill.register(
{
'modules/table-better': QuillTableBetter,
},
true,
)
Quill 的Options:
const options = {
theme: 'snow',
modules: {
syntax: {
hljs: hljs,
},
table: false,
toolbar: TOOLBAR_CONFIG,
resize: {
locale: {
center: 'center',
},
},
'table-better': {
language: 'zh_CN',
menus: ['column', 'row', 'merge', 'table', 'cell', 'wrap', 'delete'],
toolbarTable: true,
},
keyboard: {
bindings: QuillTableBetter.keyboardBindings,
},
},
}
数学公式是受fluent editor影响最坑的,所以要大书特书一下,给想要用它的朋友们劝退、劝退。需要用改到这功能的,直接改Quill吧。
它编辑后往编辑器中插入的是mathlive元素<math-field>,所以就一直按插入<math-field>标签去实现。但在粘贴文本,希望latex公式自动转化为公式。所以改成<math-field>,通过 dangerouslyPasteHTML 时,噩梦就来了:
1、转成<math-field>完全正确,但显示就是会混乱。
2、重定义formula用Quill.import('blots/block/embed'),从下一行按删除键,无法把下面行的内容合并到公式这行,在公式前往前一行删也一样。用Quill.import('blots/embed')粘贴多个公式时,你会错乱到怀疑人生,完全无从下手修改。DeepSeek会给你出监听Delete键的方案,你就陷入无尽的修改、测试、不行、再修改的浩瀚星际。
有时有AI工具反而会更浪费时间,因为它会提供一些看似很正确的方式。在无AI提供方案时,反而会更早的放弃寻找其他方式。
AI工具提供了用Delta插入、dangerouslyPasteHTML方式和直接innerHTML方式,Delta还提供分析一个节点插入一个节点,和全部分析完最后一次性插入的方式。还给了fluent editor的match node事件中实现,而不是document.addEventListener 'paste'的方式。这些方式排列组合一下,就去一个个验证试吧。我觉得人类最后可能会被AI玩死。
最后无办法,去查Quill的Blot,看到还有Quill.import('blots/inline'),发现效果勉强可以,虽然粘贴依然会有混乱,但比embed强不少。本想就这样吧,毕竟拷贝latex自动转为公式为优化体验。但使用过程又发现插入公式后,再输入时,第一个字符无法显示,自动转为英文输入法。
直至这时才彻底放弃fluent editor,BYE BYE了。其实Quill中提供了公式Blot实现,不知为何fluent editor要改成插入<math-field>。
终极做法是,完全按照Quill提供的formula去改写Blot,编辑时使用mathlive,这样完美实现:
const Embed = Quill.import('blots/embed')
class FormulaBlot extends Embed {
static create(value) {
value = value.trim();
console.log("FormulaBlot create", value)
const node = super.create()
if (window.katex == null) {
throw new Error('Formula module requires KaTeX.');
}
window.katex.render(value, node, {
throwOnError: false,
errorColor: '#f00',
});
node.setAttribute('data-value', value);
node.setAttribute('contenteditable', "false");
node.addEventListener('click', (e) => {
console.log("node.addEventListener 'click'", e)
e.stopPropagation()
const event = new CustomEvent('formula-click', {
bubbles: true,
detail: {node},
})
node.dispatchEvent(event)
})
return node
}
static value(node) {
return node.getAttribute('data-value');
}
}
FormulaBlot.blotName = 'formula'
FormulaBlot.tagName = 'SPAN'
FormulaBlot.className = 'ql-formula'
Quill.register(FormulaBlot, true)
增加自定义的公式点击事件,和在toolbar上点击都弹出自定义的mathlive编辑框,使用mathlive可看官网:https://cortexjs.io/mathfield/
quill.root.addEventListener('formula-click', (e) => {
console.log(e)
showMathEditor(e.detail.node, null)
})
quill.getModule('toolbar').addHandler('formula', () => {
let range = quill.getSelection()
if (!range) range = { index: 0 }
showMathEditor(null, range)
})
代码高亮问题:我这使用时,发现会报hljs未找到,网上Vue版都是在quill Options中要设置highlightjs,对我这无效。查看报错的源码,提示hljs是未定义,我在Options中设置hljs就好了。
以上功能就都差不多都有了,找对路子集成并不需很多时间。哎。
对于缓存和拷贝粘贴,属性完善体验,基本没有选型的坑,就是完善逻辑,AI基本能提供大部分代码:
1、浏览器有缓存:localStorage空间较小,保存当前文本最后保存的时间。IndexedDB则存文章的全部内容。
2、定时存文章内容。新编辑的文章,第一次存服务器获得一个ID,后面则用此ID作为Key去本地存储。在离开页面时存一次服务器。再次打开时,服务器上获取的版本和本地缓存的时间对比,显示最新的。
3、离开页面时,因页面关闭,很可能存储服务器不能成功执行。浏览器提供ServiceWorker功能,在关闭页面时调用,注册成功后可以后台交由系统去执行上传。但需要页面是https打开,否则注册不成功。
4、粘贴则是苦力活,要根据不同软件中拷贝的内容,去过滤掉不需要的内容,交由AI提供代码就好。
导出:Markdown格式使用turndown;Word使用html-docx,图片可以转成Base64显示在Word内,markdown查的说不支持图片显示
async function convertImageUrlToBase64(domWrap){
let imgList = domWrap.querySelectorAll('img');
console.log('加载图片数量: ', imgList.length);
await Promise.all(
Array.from(imgList)
.filter((x) => !x.src.startsWith('data'))
.map((tempimg) => {
let img = new Image();
img.setAttribute('crossOrigin', 'anonymous');
img.src = options.proxyHost ? tempimg.src.replace(location.host, options.proxyHost) : tempimg.src;
return new Promise((resolve, reject) => {
try {
img.onload = function () {
img.onload = null;
const cw = Math.min(img.width, options.maxWidth);
const ch = img.height * (cw / img.width);
const canvas = document.createElement('CANVAS');
canvas.width = cw;
canvas.height = ch;
const context = canvas.getContext('2d');
context?.drawImage(img, 0, 0, cw, ch);
const uri = canvas.toDataURL('image/jpg', 0.8);
tempimg.src = uri;
const w = Math.min(img.width, 550, options.maxWidth);
tempimg.width = w;
tempimg.height = img.height * (w / img.width);
console.log('img onload...', options.fileName, img.src, img.width, img.height, cw, ch, w, tempimg.height);
canvas.remove();
resolve(img.src);
};
img.onerror = function () {
console.log('img load error, ', img.src);
resolve('');
};
} catch (e) {
console.log(e);
resolve('');
}
});
}),
);
let canvasList = domWrap.querySelectorAll('canvas');
console.log('加载canvas数量: ', canvasList.length);
await Promise.all(
Array.from(canvasList).map((tempCanvas) => {
let img = new Image();
img.setAttribute('crossOrigin', 'anonymous');
return new Promise((resolve, reject) => {
try {
let attr = tempCanvas.getAttribute('data-toword');
let cvs = contEl.querySelector('[data-toword="' + attr + '"]');
if (!cvs && tempCanvas.className === 'fishbone_canvas') {
cvs = tempCanvas;
}
if (!cvs) return resolve();
img.src = cvs.toDataURL('image/jpg', 0.8);
const w = Math.min(cvs.width, options.maxWidth);
const h = cvs.height * (w / cvs.width);
img.width = w;
img.height = h;
const parent = tempCanvas.parentNode;
if (tempCanvas.nextSibling) {
parent.insertBefore(img, tempCanvas.nextSibling);
} else {
parent.appendChild(img);
}
tempCanvas.remove();
resolve('');
} catch (e) {
console.log(e);
resolve('');
}
});
}),
);
}
导出PDF是比较麻烦的一个事情,AI给的思路是把内容按A4纸张大小分块,转为图片加入PDF中。但AI给的代码都有问题,出现分块高度不准,内容被截成两段的情况。DeepSeek和Qwen在递归嵌套的逻辑处理上还是很弱,需要你去找出问题让它继续修改。我未使用专业的写代码工具,像Cursor。AI是越来越强了,以后程序员也许只要能读代码、测试就可以。
分割页面AI提供两个思路:
1、把一页的内容放进一个单独容器,然后把每个容器生成一张图片;
2、按一页的高度放内容,如果装不下则在前面添加空内容,把显示的内容顶到下一页。
第一个方案在分割的地方,需把当前层级属于上一页的内容节点,及其所有父节点的样式都拷贝至单独容器。会比第二个方案麻烦一些,第二个方案只需埋头往下分析,我把AI的代码修改了一些放到Github上,现在前几页高度分割没问题,越到后面越不行。有空再改。
https://github.com/zooen/htmlPagination