手把手教你手搓markdown编辑器和样式

674 阅读7分钟

本文主要涉及的核心知识为htmlCSSTypeScriptmarkdown,并非Angular

本文基于Angular 18.1.3进行开发

本文使用的Angular库版本为ngx-markdown@18.0.0

本文使用了ng-zorro,但对功能无任何影响

本文中的mdmarkdown的简写

先上最终成果截图:

环境准备

前言

之所以选择ngx-markdown这个Angular库,是因为它与Angular的兼容性适配性最好,可自由自定义化。

我会说是我尝试了所有的编辑器,最后只有它可以用吗?

但在全网我未找到如何使用该库开发一套完整的md编辑器、样式的全面教程,所以本文应邀而来。

如果你使用的是Vue、React、JavaScript,非常推荐使用ByteMD

资料地址

NPM

Github

Demo

StackBlitz

安装

npm install ngx-markdown@18.0.0 --save

安装ngx-markdown仅仅是安装了语法标记,我们还需要安装语法高亮行号命令行等插件才能完全使用

数学公式、KaTeX等不常用插件不安装,若需要可查看官方文档进行安装。

npm安装插件

语法高亮

npm install prismjs@^1.29.0 --save

emoji支持

npm install emoji-toolkit@^9.0.1 --save

图表功能

npm install mermaid@^10.9.1 --save

剪贴板功能

npm install katex@^0.16.0 --save

angular.json导入

请在angular.json文件中找到:项目名-architect-build-options中的stylesscripts

"styles": [
    "src/theme.less",
    "src/styles.css",
    "./node_modules/prismjs/themes/prism-okaidia.css",
    "./node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css",
    "./node_modules/prismjs/plugins/line-highlight/prism-line-highlight.css",
    "./node_modules/prismjs/plugins/command-line/prism-command-line.css"
],
"scripts": [
    "./node_modules/prismjs/prism.js",
    "./node_modules/prismjs/components/prism-csharp.min.js",
    "./node_modules/prismjs/components/prism-css.min.js",
    "./node_modules/prismjs/plugins/line-numbers/prism-line-numbers.js",
    "./node_modules/prismjs/plugins/line-highlight/prism-line-highlight.js",
    "./node_modules/prismjs/plugins/command-line/prism-command-line.js",
    "./node_modules/emoji-toolkit/lib/js/joypixels.min.js",
    "./node_modules/mermaid/dist/mermaid.min.js",
    "./node_modules/clipboard/dist/clipboard.min.js"
]

至此,ngx-markdown便安装完成了。

导入module文件

在你需要引入的module模块中

import { NgModule } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
import { AppComponent } from './app.component';
​
@NgModule({
  imports: [
    MarkdownModule.forRoot(),
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
})
export class AppModule { }

如果你的项目启用了standalone,则可以这样导入:

import { NgModule } from '@angular/core';
import { provideMarkdown } from 'ngx-markdown';
​
export const appConfig: ApplicationConfig = {
  providers: [
    provideMarkdown(),
  ],
};

并在你需要使用的页面这样导入:

import { NgModule } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
import { HomeComponent } from './home.component';
​
@NgModule({
  imports: [
    MarkdownModule.forChild(),
  ],
  declarations: [HomeComponent],
})
export class HomeModule { }

使用

我们只需要在页面上写上如下html代码

<markdown lineNumbers lineHighlight commandLine emoji mermaid clipboard id="currentAnchor" [data]=""></markdown>

其中lineNumbers lineHighlight commandLine emoji mermaid clipboard是我们所安装的插件,[data]则是需要渲染的md文本数据。

开发md编辑器

布局设计

目前我们已经可以渲染md语法到浏览器上了,还缺少一个输入md的方式。

于是我使用了grid布局来开发一个页面被对半分,左为textarea输入框、右为md渲染的编辑器。

<div nz-row class="content" nz-flex nzJustify="center">
    <div nz-col nzSpan="11" class="editor">
        <textarea class="textarea" nz-input [(ngModel)]="data.content"></textarea>
    </div>
    <div nz-col nzSpan="11" class="viewer" >
        <markdown lineNumbers lineHighlight commandLine emoji mermaid clipboard  id="currentAnchor" [data]="data.content"></markdown>
    </div>
</div>

这样我们就获得了一个非常简易但已经可以正常使用的md编辑器了

然而md渲染存在很大的问题,例如代码块有用户名文字中有一层奇怪的阴影复制按钮的样式太丑陋

我们便可以在angular的style.css文件中引入以下CSS代码来修复样式问题

/* markdown复制按钮样式 */
.markdown-clipboard-button {
    background-color: rgba(0, 0, 0, 0);
    box-shadow: none;
    border: none;
    color: aliceblue;
    cursor: pointer;
}
​
/* 删除代码块的用户名 */
#currentAnchor>div>pre>code>span.command-line-prompt>span {
    display: none;
}
​
/* 删除代码块左边的空白 */
pre[class*="language-"].line-numbers {
    padding-left: 0 !important;
}
​
/* 删除代码块上下的空白 */
pre[class*="language-"] {
    padding: 0 !important;
}
​
/* 去除markdown中文字的阴影 */
code[class*="language-"],
pre[class*="language-"] {
    text-shadow: none !important;
}

同步滚动

因为一篇md文档可能会非常长,如果需要寻找指定位置查看渲染效果,则需要两个div相互拖动,非常麻烦。

于是需要编写一个同步滚动的功能,使得输入框和md渲染可以按照高度百分比同步进行滚动

思路:为两个div命名ID,编写一个onScroll函数来判断滚动的容器以及对应的高度百分比,同时使另一个容器同步滚动。

具体代码:

<div nz-col nzSpan="11" class="editor">
    <textarea class="textarea" nz-input [(ngModel)]="data.content" (scroll)="onScroll('editor')" #editor></textarea>
</div>
<div nz-col nzSpan="11" class="viewer" (scroll)="onScroll('viewer')">
    <div class="markdown-container" #viewer>
        <markdown lineNumbers lineHighlight commandLine emoji mermaid clipboardid="currentAnchor"[data]="data.content">
        </markdown>
    </div>
</div>
@ViewChild('editor', { static: true })
editorRef!: ElementRef<HTMLTextAreaElement>;
@ViewChild('viewer', { static: true })
viewerRef!: ElementRef<HTMLDivElement>;
​
private isSyncing = false;
​
onScroll(source: 'editor' | 'viewer'): void {
    if (this.isSyncing) return; // 防止递归调用
​
    this.isSyncing = true;
​
    const editor = this.editorRef.nativeElement;
    const viewer = this.viewerRef.nativeElement;
​
    const sourceElement = source === 'editor' ? editor : viewer;
    const targetElement = source === 'editor' ? viewer : editor;
​
    // 计算滚动比例
    const scrollRatio =
    sourceElement.scrollTop /
    (sourceElement.scrollHeight - sourceElement.clientHeight);
​
    // 应用滚动比例到目标元素
    targetElement.scrollTop = 
    scrollRatio * (targetElement.scrollHeight - targetElement.clientHeight);
​
    this.isSyncing = false;
}

粘贴图片功能

粘贴图片的实现思路需要配合图片上传接口使用

粘贴图片的实际实现步骤是:

1.粘贴事件

2.判断粘贴事件是否为图片

3.将图片上传至图片上传接口

4.获取图片返回地址

5.将图片地址转化为md语法

6.获取输入框中的光标位置,将文本插入

思路清晰,开始实际开发,我们先为textarea新增一个粘贴事件onPaste

<textarea class="textarea" nz-input [(ngModel)]="data.content" (paste)="onPaste($event)"(scroll)="onScroll('editor')" #editor></textarea>

然后按照我们的思路进行代码编写

// 1.粘贴事件
onPaste(event: ClipboardEvent) {
    const items = event.clipboardData?.items;
​
    if (items) {
        for (let i = 0; i < items.length; i++) {
            const item = items[i];
​
            // 检查是否为图片类型
            if (item.type.startsWith('image/')) {
                const file = item.getAsFile();
                if (file) {
                    // 确保传入的是正确的文件(2.判断粘贴事件是否为图片)
                    this.uploadImage(file, event.target as HTMLTextAreaElement);
                }
            }
        }
    }
}
​
uploadImage(file: File, textarea: HTMLTextAreaElement) {
    const reader = new FileReader();
​
    reader.onload = () => {
        const base64 = reader.result as string;
​
        // 构造 Base64 上传数据
        const payload = { file: base64 };
​
        // 调用上传服务(3.将图片上传至图片上传接口)
        this.BlogService.uploadImg2(payload).subscribe({
            next: (response: any) => {
                // 图片上传成功
                if (response.code === 200 && response.data?.watermarkedUrl) {
                    // 构建图片地址(4.获取图片返回地址)
                    const imageUrl = `${API.BASE_URL}${response.data.watermarkedUrl}`;
                    // 调用md文本图片函数
                    this.insertMarkdownImage(imageUrl, textarea);
                } else {
                    console.error('Image upload failed:', response.msg);
                }
            },
            error: (err) => {
                console.error('Upload error:', err);
            },
        });
    };
​
    reader.onerror = (error) => {
        console.error('Base64 conversion error:', error);
    };
​
    reader.readAsDataURL(file);
}
​
insertMarkdownImage(imageUrl: string, textarea: HTMLTextAreaElement) {
    // 将图片地址插入为 Markdown 格式(5.将图片地址转化为md语法)
    const markdownImageSyntax = `![](${imageUrl})`;
    const start = textarea.selectionStart; // 获取光标开始位置
    const end = textarea.selectionEnd; // 获取光标结束位置
​
    // 将图片 Markdown 插入到光标位置(6.获取输入框中的光标位置,将文本插入)
    this.data.content =
        this.data.content.substring(0, start) +
        markdownImageSyntax +
        this.data.content.substring(end);
​
    // 更新 textarea 光标位置
    setTimeout(() => {
        textarea.selectionStart = textarea.selectionEnd =
            start + markdownImageSyntax.length;
    });
}

至此,粘贴图片的功能就完成了。

目前md编辑器的功能设计也基本完成了,如果你需要富文本的快捷按钮或其他功能,可以同图片插入的原理一样,通过一个指定的事件向md文本中插入指定的md语法。 (我没有这个需求,所以没有做)

开发md渲染样式

现在功能已经完成了,剩下的就是md渲染的样式,ngx-markdown的基础样式对于我而言是非常简陋难以观看的,所以我们需要通过CSS对md渲染的html进行美化。

了解渲染原理

我们打开控制台,查看md渲染的html文本就知道,实际上渲染的原理就是将md语法转换成了html并渲染在页面上,不同的语法对应不同的标记符。

我们只要知道了md语法所对应的html标记符,我们就可以轻松的进行CSS美化。

语法和标记符对应列表

接下来列出我自己使用比较多的语法和标记符对应:

一号标题:h1

二号标题:h2

三号标题:h3

四号标题:h4

五号标题:h5

普通段落:p

加粗:blockquote

代码块容器:pre

代码块文本:code>span

超链接:a

表格:table

如果你对于语法和标记符对应有任何疑问,都可以直接通过浏览器控制台进行源代码查询。

具体实现

还记得我给md所给的IDid="currentAnchor"吗?现在它终于可以用上了。

我们可以直接在styles.css文件中通过这个id以及html标记符进行指定语法的样式美化。

以我的一号标题为例:

#currentAnchor {
    h1 {
        position: relative;
        display: flex;
        border-bottom: 5px solid #6d4e00;
        line-height: 35px;
        letter-spacing: 1px;
        font-size: 25px;
        padding-left: 25px;
        color: #664900;
        text-shadow: 1px 1px 1px #8a6200;
        padding-bottom: 0px;
​
        &::before {
            content: "";
            display: flex;
            position: absolute;
            left: 0;
            top: 0;
            bottom: 0;
            margin: auto;
            width: 25px;
            height: 20px;
            background-size: 25px 20px;
            background-image: url("https://api.flowersink.com/img/再花趴趴.png")
        }
    }
}

你可以通过你的CSS技术对任何一块语法进行你所想要的美化,例如图片背景、logo等等。

我的网站博客的css美化代码是参考qklhk大佬在掘金中的贡献主题进行修改自用的:qklhk-chocolate

总结

至此,一个md编辑器的开发和美化就已经完成了。

这其实只是一个雏形,并不是一个完全成熟的md编辑器,但这也是手搓编辑器的魅力所在,你可以自定义所有你想要的便利性功能。

每个个人自己心中的编辑器都是不一样的,笔者这里也仅仅是抛砖引玉,希望能够帮助到所有想要为自己开发md编辑器的读者。