手把手做一个功能完善的富文本编辑器

计算机 412 2025-12-17 04:49

因需提供文章编辑功能,开始觉得这个富文本编辑器,应该是很成熟的功能,找个开源项目分分钟搞定。

但从一开始选型就有坑,而且越做发现要做的越多,遇到的问题也越多。


最初,觉得在外面看到的编辑器,大部分无数学公式编辑功能,所以首选就搜支持数学公式的编辑器,然后就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>的功能。折腾十多天看到希望了。


现在总结富文本编辑器所需的功能:

从展现形式分:

  1. 文字、图片、视频:所有的富文本编辑器都会提供的基础功能;
  2. 附件:其实和图片的操作雷同,仅是展现形式需自定义Blot;
  3. 表格:网上找到quill-table-better和quill-better-table(取名难道就这么难么?非得这么纠缠),看了示例,quill-table-better在点击表格后,提供一个操作toolbar,体验更好。所以选择了quill-table-better;
  4. 数学公式:Quill本身提供的公式编辑框不适合复杂公式编辑,比如编辑框外观太小,误点击到编辑框外就隐藏等,需自己修改。
  5. 代码高亮:使用highlightjs

从功能完整性、使用体验上分:

  1. 缓存:不能编辑了一大段,误刷新或断电后丢失;
  2. 粘贴:粘贴至编辑器时,默认把剪贴板中内容原封不动的拷进来。实际剪贴板中的内容并不仅仅是我们选择的那些文字,背后会有很多其他内容。不同地方拷贝,在剪贴板中的内容格式不同。从网页中拷贝的,是HTML格式;从Word中拷贝,带有很多Word的样式内容;从IDE中拷贝的,每个IDE有自己独有的格式;遇到拷贝内容中包含图片的,需要下载下来上传至自有服务器(避免其他平台内容删除而影响显示);所以需要针对每种情况分别处理;
  3. 导出:提供导出为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.');
        }

        // @ts-expect-error
        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); // word图片最大宽度
                            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('');
                    }
                });
            }),
    );

    // 3. 将canvas转为Base64编码, 方便word保存
    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


喜欢 | 0 收藏 | 0
提示:如果图片无法显示,请检查网页地址前缀是否被改成https://,请使用http://访问
文章评论