本文主要涉及的核心知识为
html、CSS、TypeScript、markdown,并非Angular。本文基于
Angular 18.1.3进行开发本文使用的Angular库版本为
ngx-markdown@18.0.0本文使用了
ng-zorro,但对功能无任何影响本文中的
md为markdown的简写
先上最终成果截图:
环境准备
前言
之所以选择ngx-markdown这个Angular库,是因为它与Angular的兼容性适配性最好,可自由自定义化。
我会说是我尝试了所有的编辑器,最后只有它可以用吗?
但在全网我未找到如何使用该库开发一套完整的md编辑器、样式的全面教程,所以本文应邀而来。
如果你使用的是Vue、React、JavaScript,非常推荐使用ByteMD
资料地址
安装
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中的styles和scripts
"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 = ``;
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编辑器的读者。