首发于 C# Star
使用Blazor开发内部后台(七):强大且友好的富文本编辑器TinyMCE

使用Blazor开发内部后台(七):强大且友好的富文本编辑器TinyMCE

背景

在后台管理系统中,偶尔需要编辑一些供前台展示的内容,这些内容不仅包括文本,还包括文本的格式(加粗、斜体等)、列表、图片、超链接等等。对于一名专业的开发者,在类似场景下(比如为开源项目写README),往往会学习和编写诸如Markdown这样的语法;但对非专业的运营或市场人员来说,学习Markdown显然不是一件有趣的事情。

此时就需要富文本编辑器来提供可视化( 所见即所得 )的体验。笔者写本文时,知乎的富文本编辑器就在上方随时待命。

知乎写文章时的富文本编辑器

在社区同行的推荐下,我了解到一款富文本编辑器:TinyMCE。体验一番之后深受震撼,在完成后台功能开发后便立即开始撰写此文,力求趁新鲜之余将开发过程中的经验、感想一一记录,供有心的读者参考。

TinyMCE

【笔者注】如无特殊说明,本文代码均基于Blazor WASM模式。

便捷的第一步

为什么笔者在标题强调TinyMCE“友好”?因为它是为数不多官方支持Blazor的富文本编辑器之一。

在Client项目内打开Nuget包管理器,搜索“TinyMCE”,安装TinyMCE和TinyMCE.Blazor两个包。

Nuget包

此时生成Client项目,IDE会将包内的库文件放置到wwwroot/lib目录下:

库地址

打开项目wwwroot目录下的index.html,在body内添加js文件引用:

 <script src="lib/tinymce/tinymce.min.js"></script>
 <script src="_content/TinyMCE.Blazor/tinymce-blazor.js"></script>

有issue提及这里JavaScript引用的顺序似乎会对项目运行有影响,笔者建议写在 _framework/blazor.webassembly.js 之前。

现在我们其实就可以使用TinyMCE了: <TinyMCE.Blazor.Editor @bind-value=@_richText />

友好的云和自部署支持

在笔者看来,TinyMCE很好地平衡了开源免费和商业化。其中一点就是它对云部署和自部署的支持。

开发者可以注册Tiny Cloud账号,享受它提供的便捷部署、CDN加速和付费版功能;也可以在自己的服务器手动部署和升级。

如果采用手动部署,建议指定组件加载 tinymce.min.js 的路径:

 <TinyMCE.Blazor.Editor ScriptSrc="lib/tinymce/tinymce.min.js" />

简洁的表单支持

在实际业务场景中,富文本编辑器往往会跟其他组件一起在表单中使用。在官方示例中,提供了使用原生表单的案例:

<EditForm EditContext="@CurrentEditContext">
    <DataAnnotationsValidator />
        <label>Content</label>
        <Editor Field="() => Model.Content" 
          @bind-Value="Model.Content" 
          ValidationOnInput="@true"/>
        <ValidationMessage For="() => Model.Content" />
</EditForm>
@code {
    private Model Model { get; set; } = new Model();
    private EditContext? CurrentEditContext;
    protected override void OnInitialized()
        CurrentEditContext = new(Model);
        base.OnInitialized();
}

在表单中,除了bind-value之外,还必须要指定Field属性: Field="() => Model.Content"

不过更多时候,我们使用的可能是开源组件库提供的Form组件。比如AntBlazor中的 Form组件

<AntDesign.Form @ref=@_form Model=@_formData OnFinish="OnFinish" LabelColSpan="6" WrapperColSpan="12">
   <FormItem Label="富文本">
     <TinyMCE.Blazor.Editor @bind-Value="context.RichText" Field="() => context.RichText"
        ScriptSrc="lib/tinymce/tinymce.min.js" />
   </FormItem>
    <FormItem WrapperColOffset="12" WrapperColSpan="12">
        <Button Type="@ButtonType.Primary" Size="@ButtonSize.Large" Loading=@_interacting Shape="round" HtmlType="submit">
        </Button>
    </FormItem>
</AntDesign.Form>

上手是不是很友好?不过我们的目的地还远不止此。

丰富灵活的配置支持

富文本编辑器提供了一个工具栏,工具栏里的成员决定了各个功能的入口。TinyMCE的强大之处在可配置化方面就揭开了冰山一角。

官方提供的TinyMCE.Blazor包将富文本编辑器的初始化配置封装为一个 Dictionary<string, object> 对象。因此,我们先写一个字典对象:

        public static Dictionary<string, object> TinyMCEConfig { get; set; } = new()
            // 配置项

并将该字典赋值给组件的 Conf 属性 :

<TinyMCE.Blazor.Editor @bind-Value="context.RichText" Field="() => context.RichText"
    Conf="@TinyMCEConfig"
    ScriptSrc="lib/tinymce/tinymce.min.js" />

然后,让我们添加一些基础配置项——

TinyMCE的右下角支持拖动来改变编辑器的长度和高度,我们可以在初始化时指定精确值或者变动范围:

{ "min_width", 500 },
{ "max_width", 1000 },
{ "min_height", 500 },
{ "max_height", 1000 },
大小调整

接下来就是非常灵活的工具栏图标配置:

{ "toolbar", "styles " +
    "| fontfamily fontsize forecolor " +
    "| bullist numlist"+
    "| link image " + 
    "| bold italic " +
    "| alignleft aligncenter alignright lineheight"},
{ "toolbar_mode", "wrap" },

这里的配置项设计非常有趣,本质上工具栏的配置就是一个字符串:

  • 每个图标对应一个特殊字符串
  • 图标之间用空格分隔
  • 图标还拥有组别的概念
  • 每个图标组用竖线分隔
  • 图标顺序即配置中特殊字符串的顺序

因此笔者特意将每个图标组的字符串做了换行,上述配置的工具栏UI如下:

轻松配置的工具栏

同时针对工具栏多行图标,还贴心地提供了四种处理方式——

“Floating”:

Floating

“Sliding”:

Sliding

“Scrolling”:

Scrolling

“Wrap”:

Wrap

基于如此简便的设计,只需要一篇 简单的文档 ,就可以让使用者很方便地查找和配置工具栏图标。

富文本编辑器经常需要面对风格迥异的使用场景。TinyMCE设计了一套可插拔的 插件机制 来应对丰富的业务需求。在官方付费版提供了很多更强功能插件的同时,开源免费的插件也是百花齐放:

一张图截不下的插件列表

比如上述配置中的“link image”两个图标,对应常见的超链接和图片功能,分别来自于两个开源免费的插件: Link Image 。要使用这两个图标,则还要在初始化配置中说明引用的插件:

{ "plugins", "link image" },

对免费版来说,默认初始化配置会在富文本编辑器的右上角添加一个升级付费版的按钮,点击后会打开新标签跳至官方付费介绍页面。这也是推广商业化服务的方式之一。

升级付费版

但TinyMCE也大方地允许开发者关闭它:

{ "promotion", false },

强大的图片支持

图片支持是一个比较常见但又略有特殊的需求。特殊之处在于使用场景多种多样:图片的来源是文件还是内存(截图),图片的去处是本地(Base64)还是上传?

TinyMCE在对图片的支持上做到了由简入繁的灵活设计,用于支撑不同的业务场景。笔者也将从最简单的需求开始逐步介绍在Blazor中使用它的图片支持。

TinyMCE在内置的复制粘贴功能中,支持将内存中的图片粘贴为base64文本格式。只需要在初始化配置中添加:

{ "paste_data_images", true },

将粘贴的图片自动转化为Base64嵌入富文本,实现固然简单,但对大体积图片或某些业务场景而言仍不能接受。

而上一节提到的Image插件,默认启用了图片引用功能。点击image图标,出现弹窗如下:

图片引用

输入图片的URL来源,即可嵌入图片,还可以设置图片的长度和高度(默认只需要修改长度,高度即会自动按原比例缩放)。但这也不满足绝大多数场景的需要。图片不能在此上传,总要有其他地方来上传呀!当然,开发者可以在其他地方使用单独的文件上传组件;但如何就基于TinyMCE提供的功能来实现呢?

首先在初始化配置中添加:

{ "image_uploadtab", true },

显然,服务端需要提供一个图片上传的URL。最简单的情况是上传URL不需要鉴权,那么只需要再配置上传URL即可:

{ "images_upload_url", "https://my_domain/image_upload" }

再点击图片图标,弹窗里出现了新的Tab标签“Upload”:

图片上传

就是这么简单,我们已经完成了图片上传的支持——既支持选择文件,也支持截图粘贴(和知乎编辑器图片上传的体验是一样的)。我们还可以进一步限制上传的图片文件类型:

{ "images_file_types", "jpg,jpeg,png,gif" }

不过,实际生产中出于安全考虑,文件上传的公开接口一定是需要鉴权的。

如果是同域名下基于Cookie鉴权,那么只需要添加:

{ "images_upload_credentials", "true" }

这样上传请求会自动携带域名下的Cookie,就可以通过图片上传接口的鉴权。

但还存在其他业务场景:上传是跨域的,或采用了自设计的token机制等等……总之我们需要 自己来控制调用图片上传接口的逻辑 。TinyMCE考虑到了这一点,开放了 images_upload_handler 配置,可以将其理解成 一个基于约定的“方法签名” ,开发者可以实现自己的handler方法来替换官方默认值。

官方文档提供了一个示例:

const example_image_upload_handler = (blobInfo, progress) => new Promise((resolve, reject) => {
  const xhr = new XMLHttpRequest();
  xhr.withCredentials = false;
  xhr.open('POST', 'postAcceptor.php');
  xhr.upload.onprogress = (e) => {
    progress(e.loaded / e.total * 100);
  xhr.onload = () => {
    if (xhr.status === 403) {
      reject({ message: 'HTTP Error: ' + xhr.status, remove: true });
      return;
    if (xhr.status < 200 || xhr.status >= 300) {
      reject('HTTP Error: ' + xhr.status);
      return;
    const json = JSON.parse(xhr.responseText);
    if (!json || typeof json.location != 'string') {
      reject('Invalid JSON: ' + xhr.responseText);
      return;
    resolve(json.location);
  xhr.onerror = () => {
    reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
  const formData = new FormData();
  formData.append('file', blobInfo.blob(), blobInfo.filename());
  xhr.send(formData);
tinymce.init({
  selector: 'textarea',  // change this value according to your HTML
  images_upload_handler: example_image_upload_handler

在最下方可以看到只要将 images_upload_handler 指定为example_image_upload_handler即可,同时指定handler就不再需要指定 images_upload_url images_upload_credentials 了——因为handler里本身就需要和包含了它们。

对前端JS框架来说,到此就已经结束了。但对Blazor来说,因为 images_upload_handler 要求配置一个JS方法,我们无法使用C#代码实现这样的设置。TinyMCE.Blazor库应该是考虑到了这种情况,因此除了 Conf 属性,还提供了另一个配置属性 JsConfSrc ,文档对它的解释是:

Use a JS object as base configuration for the editor by specifying the path to the object relative to the window object.
通过指定对象相对于window对象的路径,使用JS对象作为编辑器的基本配置。

首先我们需要在wwwroot下增加一个js文件,如图:

自定义handler

接着让我们改写一下官方示例的example_image_upload_handler:

const tinymce_custom_images_upload_handler = (blobInfo, progress) => new Promise((resolve, reject) => {
    const jwt = localStorage.getItem('myToken');
    if (!jwt) {
        return;
    const xhr = new XMLHttpRequest();
    xhr.withCredentials = false;
    xhr.open('POST', 'https://my_domain/image_upload');
    xhr.setRequestHeader('Authorization', 'Bearer '.concat(JSON.parse(jwt)));
    xhr.upload.onprogress = (e) => {
        progress(e.loaded / e.total * 100);
    xhr.onload = () => {
        if (xhr.status === 401 || xhr.status === 403) {
            reject({ message: 'HTTP Error: ' + xhr.status, remove: true });
            return;
        if (xhr.status < 200 || xhr.status >= 300) {
            reject('HTTP Error: ' + xhr.status);
            return;
        const json = JSON.parse(xhr.responseText);
        if (!json || typeof json.location != 'string') {
            reject('Invalid JSON: ' + xhr.responseText);
            return;
        resolve(json.location);
    xhr.onerror = () => {
        reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
    const formData = new FormData();
    formData.append('file', blobInfo.blob(), blobInfo.filename());
    xhr.send(formData);

该handler会从LocalStorage获取JsonWebToken,设置为Authorization请求头后发送给后端图片上传接口。

然后我们在handler下方再增加一个自定义的window对象 tinymce_config

window.tinymce_config = {
    images_upload_handler: tinymce_custom_images_upload_handler,

并且回到index.html,在body里添加对该js文件的引用。笔者最终的引用如下:

<script src="lib/tinymce/tinymce.min.js"></script>
<script src="_content/TinyMCE.Blazor/tinymce-blazor.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
<script src="js/tinymce_images_upload_handler.js"></script>

最后指定组件 JsConfSrc 属性值即可:

 <TinyMCE.Blazor.Editor @bind-Value="context.RichText" Field="() => context.RichText"
    Conf="@TinyMCEConfig"
    JsConfSrc="tinymce_config"
    ScriptSrc="lib/tinymce/tinymce.min.js" />

读者可以进一步改写handler适应更多的业务场景,比如:很多系统往往会使用云厂商的对象存储服务(OSS),而在传输文件到OSS时有2种方案——服务端传输和客户端直传。前者跟笔者上述的业务场景类似;而后者客户端在上传图片前,还需要跟后端请求允许上传文件至OSS的Policy Token,最后直接向OSS地址上传。至此,相信有需要的读者可以自行改写handler来满足这样的业务需求。

笔者到达了目的地,但还是有一点遗憾:笔者最初想使用C#的HttpClient来实现图片上传的逻辑(代码更简单),然后企图封装一个简单的JS方法,基于Blazor中JS和.NET互操作机制,让JS方法调用实现图片上传的.NET方法。但多次尝试无果(尤其是对Progress的支持),最终还是选择了纯JS实现。如果读者有什么更好的方案,欢迎不吝赐教!

总结

TinyMCE拥有现代化的UI、灵活的配置、丰富的插件、清晰的文档、友好的商业策略,这让笔者在开发过程中没有遇到太多的困惑,也感受到了背后团队的用心和细致。更让笔者佩服的是它在开源免费和商业化之间的平衡,它的云服务可以给自己带来收入,而它的插件机制不仅为付费客户提供了更多更好的选择,同时也培养了一个围绕自身的生态。

在TinyMCE的 Github主页 ,第一句话写的就是:

The world's #1 open source rich text editor.

笔者觉得它的确配得上这样的自信。

最后,回顾上文,笔者最终的初始化配置如下,供读者参考:

public static Dictionary<string, object> TinyMCEConfig { get; set; } = new()
    { "min_width", 500 },
    { "max_width", 1000 },
    { "min_height", 500 },
    { "max_height", 1000 },
    { "toolbar", "styles " +
        "| fontfamily fontsize forecolor " +
        "| bullist numlist"+
        "| link image " + 
        "| bold italic " +
        "| alignleft aligncenter alignright lineheight"},
    { "toolbar_mode", "wrap" },
    { "plugins", "link image lists" },
    { "paste_data_images", true },