likes
comments
collection
share

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

作者站长头像
站长
· 阅读数 8

前言

大家好啊,这里是懂一点项目管理和前端开发的狗子。

最近狗子的人生已经重启完毕,即将开始React开发的新篇章。但说实话,我React开发的技术确实还在基础水平,想检验一下自己的项目开发能力。

于是藜麦计划就诞生了,这是藜麦计划的第一个项目,纯练手项目,不喜勿喷。

上集回顾

上一篇我们主要讲了一下Markdown编辑器项目的需求基本开发思路

这一篇主要讲述基础功能的开发部分逻辑的优化,很多内容的开发会有些重复,我只挑重点讲,因为部分代码都是工作量的问题,没有什么难点。

大纲如下:

  • 基础显示

  • 列表 - 有序,无序

  • 任务列表

    ....

  • 总结

详细代码直接来githubantd-tools

话不多说,我们开始!

正文

基础显示

上一篇其实我们已经将基础项目和关键的几个文件建好了,经过一些小小的优化(css优化)之后呢,页面已经长成这样子了(长成啥样子不重要~~,真的~~!)

export default function MarkdownToHtml() {
    const [markdownText, setMarkdownText] = useState(textDemo);
    // 分词,解析为tokens
    // const tokens = [];
   
    // 根据上一步所得到的tokens,形成抽象语法树
    // const ast = {};

    const style = {
        backgroundImage: `url(${MarkdownBg})`,
        backgroundSize: '100% 100%',
        backgroundRepeat: 'no-repeat'
    };

    return (
        <>
            <div className="h-full w-full flex backdrop-opacity-10 backdrop-invert rounded" style={style}>
                <div style={{ flex: `0 0 40rem`}}>
                    <MarkdownInput defaultValue={markdownText} onChange={setMarkdownText} />
                </div>
                <Typography className="w-full p-2 text-left border
                border-stone-300 border-solid rounded"
                    style={{
                        backgroundColor: 'rgba(255, 255, 255, 0.9)',
                        overflow: 'auto',
                    }}>
                    <GenerateHtml node={ast} />
                </Typography>
            </div>
        </>
    );
}

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

可以很明显看到左边的编辑器和右边的显示框已经设置好了宽高,那么最主要还要做两点,基础显示就完成了。

  • 左右文字的实时同步
  • 测试用例

左右文字的实时同步

这块就是简单的左边改动,右边响应。

在上一篇文章中,我们已经能够拿到markdownText,只是tokensast还没有生成,我们假如ast是一个空对象,直接传入GenerateHtml组件中,看看ast需要一个什么样子的类型。

众所周知,ast是一棵树,那么正常在开发过程中,我们遇到树结构都会想到一个属性children,加上我们此时的需求是:左右文字同步

所以我做主,再定义一个content来显示内容。

再因为整个需求是markdown语法,不同的文字有着不同的类型,所以type字段肯定逃不掉。

最终,我们的ast的类型就定好了:

// parse.ts

export interface ParseAST {
    type: MarkdownElement | typeof ParseNodeRoot;
    children: ParseAST[];
    content: string;
}

这时候就有小伙伴要问了,MarkdownElement是什么, ParseNodeRoot又是什么?

节点类型肯定是一个枚举,不用想哈,肯定是,用js的话就是一个映射对象。

// const.ts

// 根节点单独拿出来
export const ParseNodeRoot = 'root';

// markdown节点类型
export enum MarkdownElement {
    // 标题
    Heading = 'heading',
}

好了,接下来轮到渲染代码上场了!

// generate-html.tsx

export default function GenerateHtml({ node }: Props) {
    if (node.type === ParseNodeRoot) {
        return (
            <>
                {node.children?.map((child, index) => <GenerateHtml key={index} node={child} />)}
            </>
        );
    }
    
    return node.content;
}

是不是很easy啦

然后俺们把index.tsx中的ast改一下,改成一个简单的对象就可以看到右侧的显示了:

// 函数MarkdownToHtml
...
const ast: ParseAST = {
    type: ParseNodeRoot,
    children: [{
        type: MarkdownElement.Heading,
        content: markdownText
    }]
}
...

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

嘿嘿,出来了!

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

测试用例

想致富,先修路

敏捷开发嘛,总要了解一下TDD(测试驱动开发),先写用例,再开发实际代码,冲!

我们要做什么呢?

  • 标题
  • 段落
    • 加粗段落
    • 斜体
    • 删除线
    • 行内代码
    • 链接
  • 列表
    • 有序列表
    • 无序列表
    • 任务列表
  • 引用
  • 图片
  • 分割线

都是基础的功能,话不多说,直接上用例!

const textDemo = `
# 标题

这是**一个**段落,**这是一个加粗字体**,这也是__加粗的方式__

- 无序列表项 1
- 无序列表项 2
- 无序列表项 3

---

这是第*二个*段落,*这是一个斜体*,这也是_斜体_的写法

1. 有序列表项 1
2. 有序列表项 2
3. 有序列表项 3

---

这是第~~三个~~段落,~~这是一个删除线~~

这是第\`四个\`段落,\`const inlineCode = "这是一个行内代码";\`

> 段**落**1
> 段落2
> 段\`落\`3

这是第五个段落,内部有[链接文字,链接到百度](https://www.baidu.com "标题"),没想到吧!

[不带标题的链接文字,链接到百度](https://www.baidu.com)

这是第六个段落

![图片](https://p4.itc.cn/q_70/images03/20230512/32c7ad09b5904bea8506d74f96483000.png)

> 这是第二段引用

- [x] 任务列表1已完成
- [ ] 任务~~列表2~~
- [ ] 任务列表31**加粗**

这是第七个段落

`;

标题

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

很好理解,有多少个 # 就有多少级标题,最少1个,最多6个,同时#后面必须跟一个空格。

核心正则

/^\s*#{1,6}\s+/

匹配格式:

# H1  
## H2  
### H3
  • ^ 表示行的开始。
  • \s* 匹配0个或多个空白字符(包括空格、制表符、换行符等)。
  • #{1,6} 匹配1到6个#字符,代表一级到六级的标题。
  • \s+ 匹配1个或多个空白字符,这通常是为了匹配标题后面的空格。

解析为Token

在上一篇中我们解析token的文件tokenize.ts并没有开始写

现在开始解析第一个功能:标题

首先创建主函数tokenize,并且返回一个数组,这个数组保存着我们一行一行解析的Markdown语法,我们简要称它的类型为Token

// tokenize.ts

export function tokenize(markdownText: string) {

    // 将获取到的markdown文本按照行来分割
    const lines = markdownText.split('\n');

    // 用于存储解析出来的token
    const tokens: Token[] = [];
    
    // 将markdown文字转为Token
    // ...
  
    return tokens;
}

Token类型我们该如何定义呢?

我的理解中Token类型是一个集合体,它可以是标题类型,可以是段落类型,也可以是其他类型

目前先写标题类型:Heading,它应该有几个基本属性:类型内容等级

它的等级又限定死了1,2,3,4,5,6

// type.d.ts

// 标题等级类型
export type TitleLevel =  1 | 2 | 3 | 4 | 5 | undefined;

// 标题类型
interface HeadingToken {
    type: MarkdownElement.Heading;
    level: TitleLevel;
    content: string;
}

export type Token = HeadingToken;

接下来我们就需要将lines中每一个数据转为tokens中的token

咱们用最简单的方案来放入数组,for循环。

for (const line of lines) {
    // 先将前后空白符清理
    const trimLine = line.trim();
    
    // 判断是否是标题
    if (trimLine.match(markdownRegex.heading)) {
        // 获取标题的级别
        const level = (trimLine.match(markdownRegex.heading)?.[0].trim().length || 6) as TitleLevel;
        // 创建标题的 token
        tokens.push({
            type: MarkdownElement.Heading,
            level: level,
            content: trimLine.replace(markdownRegex.heading, '').trim(),
        });
    }

这里咱们暂时不去管标题中带有斜体、内置代码、删除线等混合操作,等我们将段落模块开发完之后再加上。

转为AST

在上面 左右文字的实时同步 时,我们简单的写了一个ParseAST类型,用于给出最终AST的结构,接下来我们简单的写一下parse函数.

parse函数有几点需求:

  • 接收tokens数组
  • 将数组结构转为AST对象
  • 返回ParseAST类型的对象
// parse.ts

export function parse(tokens: Token[]) {
    // 初始化抽象语法树的根节点
    const ast: ParseAST = {
        type: ParseNodeRoot,
        children: [],
        content: ''
    }

    let currentList: ParseAST | null;
 
    tokens.forEach((token) => {
        //...
    });

    return ast;
}

那标题怎么写呢

标题就是把token里面的数据push进ast的数组即可(暂时

因为不涉及到标题内部特殊格式的嵌套,所以先暂时直接进ast.children

// parse.ts

export function parse(tokens: Token[]) {
    // 初始化抽象语法树的根节点
    const ast: ParseAST = {
        type: ParseNodeRoot,
        children: [],
        content: ''
    }

    let currentList: ParseAST | null;
 
    tokens.forEach((token) => {
        switch (token.type) {
            case MarkdownElement.Heading:
                // 标题和段落实际处理一致
                ast.children!.push(token);
                break;
           default:
                break;
        }
    });

    return ast;
}

因为是第一个解析为AST的模块,所以我展示全量代码,后续只会展示部分代码,不然以为在水字数。

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

到此,我们的标题模块就可以进入渲染阶段了,让我们看看渲染出来的结果是什么样子的。

渲染

标题的渲染我们选用Antd的Typography组件,它的Text正符合我们的要求。

Typography 排版

同时GenerateHtml组件也使用switch语句来分类渲染

// generate-html.tsx

interface Props {
    node: ParseAST;
    keyValue?: number | string;
}

/** 生成标题 */
function Heading({ node }: Props) {
    return <Title level={node.level}>{node.content}</Title>
}


export default function GenerateHtml({ node }: Props) {
    if (node.type === ParseNodeRoot) {
        return (
            <>
                {node.children?.map((child, index) => <GenerateHtml key={index} node={child} />)}
            </>
        );
    }

    switch (node.type) {
        case MarkdownElement.Heading:
            return <Heading node={node} />;
        default:
            return null;
    }
}

周树人:抽离组件是我们良好的习惯

至此,我们终于可以展示效果图了!

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)


段落

苏格拉地漏曾经说过:万物皆可段落

标题其实只是给大家举了一个栗子🌰,其实我们更加应该从段落开始写代码。

我说:标题是段落,列表是段落,表格里面也有段落。

那段落它应该是什么?

段落应该是一个集合,一个普通文字、一个加粗文字,一个斜体.....(不水字数了)

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

说了这么多废话,不如看一行代码(我真棒!又压上韵了)

(˵¯͒〰¯͒˵)

核心正则

就正则我会列的详细一点,懂的大佬可以只扫一眼,不太了解的大佬们详细看看。

加粗

bold: /\*\*(.*?)\*\*|__(.*?)__/g,

匹配格式:**bold text** 或 __bold text__

这个正则表达式用于匹配两种模式,分别是 **__ 包围的文本。我们可以分解这个正则表达式来更详细地解释其含义:

  1. /\*\*(.*?)\*\*|:这部分匹配由 ** 包围的文本。
    • \*\*:匹配两个星号字符 **
    • (.*?):这是一个捕获组,用于捕获 ** 之间的内容。
      • .:匹配除换行符之外的任何单个字符。
      • *?* 表示前面的元素(即 .)可以重复零次或多次,而 ? 在这里使 * 变为非贪婪模式,即尽可能少地匹配字符。
    • \*\*:再次匹配两个星号字符 **
  2. |:匹配其左侧或右侧的任何部分。
  3. __(.*?)__/:这部分匹配由 __ 包围的文本。
    • __:匹配两个下划线字符 __
    • (.*?):同样是一个捕获组,用于捕获 __ 之间的内容,其意义与上述相同。
    • __/:再次匹配两个下划线字符 __
  4. g:匹配字符串中的所有可能位置(只说一次)。

斜体

italic: /\*(.*?)\*|_(.*?)_/g,

匹配格式: *italicized text* 或 _bold text_

斜体就是加粗的正则在两边各减少了一个*_,其余的就不重复了。

删除线

delete: /~~(.*?)~~/g,

匹配格式:~~The world is flat.~~

删除线也很好懂,只要知道中间是一个匹配任意字符的非贪婪算法即可。

行内代码块

inlineCode: /`(.*?)`/g,

匹配格式:`code`

行内代码块也是同样的,被`包裹的文字都是行内的代码块(当然,我们也可以用三个来显示行内代码,这块等我们写完代码块之后再看)

链接文字

link: /\[([^\]]*)\]\(([^)]*)\)/g,

匹配格式:[title](https://www.example.com)

链接文字和图片差不多,只是少了一个!

  • \[ 和 \]:分别匹配一个左方括号 [ 和一个右方括号 ]。同样,因为在正则表达式中,方括号是特殊字符,所以我们需要使用 \ 对其进行转义。

  • \( 和 \):分别匹配一个左圆括号 ( 和一个右圆括号 )。同样的原因,我们需要使用 \ 对其进行转义。

  • ([^\]]*):这是一个捕获组。它匹配任何不是右方括号的字符([^\]] 表示非右方括号的字符集合),并且 *表示匹配零个或多个这样的字符。

  • \(([^)]*)\):这是另一个捕获组,嵌套在第一个捕获组内部。它匹配任何不是右圆括号的字符([^)] 表示非右圆括号的字符集合),并且 * 表示匹配零个或多个这样的字符。

解析为Token

段落这些特殊表达式的解析和一行代表一个格式的表达式不一样。

段落可以在大部分表达式内部解析,而且一种段落格式可以在一行中出现多次,根据这两个特性,我认为在解析时,应该将这类格式归为子集,或者属性

所以我是这么写的

在普通一行(这一行都是文字,没有其他特殊格式)中,我们将行内的特殊格式均加入到行对象中,并且这些格式都是以数组形式存储。

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

我发现,无论是加粗斜体删除线行内代码,都可以解析为同一个对象格式;

它包括文字类型不带格式的文字内容带格式的内容

所以我称它们为InlineToken

interface ExtraTokenBase {
    content: string;
    match: string;
}

interface BoldToken extends ExtraTokenBase {
    type: MarkdownElement.Bold;
}

interface ItalicToken extends ExtraTokenBase {
    type: MarkdownElement.Italic;
}
interface InlineCodeToken extends ExtraTokenBase {
    type: MarkdownElement.InlineCode;
}

interface DeleteToken extends ExtraTokenBase {
    type: MarkdownElement.Delete;
}

export type InlineToken = BoldToken | ItalicToken | InlineCodeToken | DeleteToken;

只要不同的类型的函数经过相同逻辑处理之后,我们就能够得到这些特殊格式的token对象了。

/** 匹配加粗格式 */
function matchBoldText(text: string) {
    return matchText(text, markdownRegex.bold, MarkdownElement.Bold);
}

/** 匹配斜体格式 */
function matchItalicText(text: string) {
    return matchText(text, markdownRegex.italic, MarkdownElement.Italic);
}

/** 匹配删除线格式 */
function matchDeleteText(text: string) {
    return matchText(text, markdownRegex.delete, MarkdownElement.Delete);
}

/** 匹配行内代码格式 */
function matchInlineCodeText(text: string) {
    return matchText(text, markdownRegex.inlineCode, MarkdownElement.InlineCode);
}

function matchText(text: string, regex: RegExp, type: InlineToken['type']) {
    const list: InlineToken[] = [];
    const matchs = text.matchAll(regex);

    for (const match of matchs) {
        // 0位是完整匹配项,1/2位是内容
        // 由于我们使用了|来分隔两个模式,所以只有一个捕获组会有内容
        list.push({
            type: type,
            content: match[1] || match[2],
            match: match[0]
        });
    }

    return list;
}

不知道大家有没有发现,或者好奇,Link去哪了?

我在写的时候,link确实不好处理,它自带了多个属性:hrefalttitle

我需要单独拿出一个类型:LinkToken

interface LinkToken extends ExtraTokenBase {
    type: MarkdownElement.Link;
    href?: string;
    title?: string;
}

InlineToken也要加上LinkToken

export type InlineToken = BoldToken | ItalicToken | InlineCodeToken | DeleteToken | LinkToken;

处理函数也无法复用,必须单独处理:

/** 匹配行内链接,需要带其他数据,所以单独拿出一个函数 */
function matchLink(text: string) {

    const list: InlineToken[] = [];

    const matchs = text.matchAll(markdownRegex.link);

    for (const match of matchs) {
 
        // href中可能带有标题
        const hrefMatch = match[2];

        // 单独匹配上标题
        const titleMatch = hrefMatch.match(/^([^"]*)\s*"([^"]*)"$/);

        list.push({
            type: MarkdownElement.Link,
            content: match[1],
            match: match[0],
            href: titleMatch?.[1].trim() ?? hrefMatch.trim(),
            title: titleMatch?.[2] ?? ''
        });
    }
    return list;
}

测完没问题之后,给标题也加上对应的属性,由于标题都是加粗,所以除了加粗都要加。

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

转为AST

我的想法是这样的,所有的一行文字都可以在最终转为段落类型,每个段落类型中又可以拆分成一个数组,这个数组包含了这一行中所有的格式数据。

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

首先我们将整行转为一个parseAST对象,并将内容放进数组的0号位:

const result: ParseAST = {
    ...token,
    type,
    content: token.content ?? '',
    children: [{
        type: MarkdownElement.Text,
        content: token.content
    }]
}

然后我们就可以对每一句话里面的内容进行切割了

function splitToAST(rootChildren: ParseAST[], tokenList: InlineToken[], type: MarkdownElement) {
    const result: ParseAST[] = [];
    rootChildren.forEach(childText => {
        // 已经处理过的数据直接放入数组
        if (childText.type !== MarkdownElement.Text) {
            result.push(childText);
            return;
        }
        let content = childText.content ?? '';
        tokenList.forEach(item => {

            // 匹配Text类型的数据
            const index = content.indexOf(item.match);

            // 未匹配上
            if (index === -1) {
                return;
            }

            // 开头未匹配上,不管了,直接进去
            if (index > 0) {
                result.push({
                    type: MarkdownElement.Text,
                    content: content.slice(0, index)
                })
            }

            // 将匹配上的数据放入数组
            result.push({
                ...item,
                type,
                content: item.content,
            })

            // 切掉之前匹配上的数据,重新走流程
            content = content.slice(index + item.match.length);
        });

        // 如果最终还有剩余的文字(未匹配上),则直接加进去
        if (content) {
            result.push({
                type: MarkdownElement.Text,
                content: content
            })
        }
    });
    return result;
}

那好奇的大佬们就会问,如果一行中出现多个类型怎么办?

那还能怎么办呢?

还得是按顺序一次一次来做切割

当然,这种写法会导致性能很一般


/** 解析段落 */
function parseParagraph<T extends ParagraphToken>(token: T, type: MarkdownElement = MarkdownElement.Paragraph) {
    const result: ParseAST = {
        ...token,
        type,
        content: token.content ?? '',
        children: [{
            type: MarkdownElement.Text,
            content: token.content
        }]
    }

    if (token.inlineCode?.length) {
        result.children = splitToAST(result.children!, token.inlineCode, MarkdownElement.InlineCode);
    }

    if (token.bold?.length) {
        result.children = splitToAST(result.children!, token.bold, MarkdownElement.Bold);
    }

    if (token.delete?.length) {
        result.children = splitToAST(result.children!, token.delete, MarkdownElement.Delete);
    }

    if (token.italic?.length) {
        result.children = splitToAST(result.children!, token.italic, MarkdownElement.Italic);
    }

    if (token.link?.length) {
        result.children = splitToAST(result.children!, token.link, MarkdownElement.Link);
    }

    return result;
}

整体的流程没啥说的,就是将在Tokens数组中匹配上的数据转为将要渲染的结构对象。

渲染

效果图:

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

PS:关于斜体不斜的问题,目前我还在查为什么,等第三篇发布了应该就有结果了。

渲染代码:


/** 生成段落 */
function ParagraphContent({ node, keyValue }: Props) {
    if (!node.content) {
        return null;
    }

    function getContent(item: ParseAST, index: number) {
        const content = item.content;

        if (!content) {
            return null;
        }
        if (item.type === MarkdownElement.Text) {
            return <span key={index}>{content}</span>
        }

        if (item.type === MarkdownElement.Link) {
            return (
                <Link
                    key={index}
                    href={item.href}
                    title={item.title}
                    target='_blank'>
                    {content}
                </Link>
            )
        }

        return <Text
                key={index}
                strong={item.type === MarkdownElement.Bold}
                italic={item.type === MarkdownElement.Italic}
                code={item.type === MarkdownElement.InlineCode}
                delete={item.type === MarkdownElement.Delete}>
                    {content}
                </Text>
    }
    return (
        <>
            {
                node.children!.length > 0
                    ? node.children!.map(getContent)
                    : <span key={keyValue}>{node.content}</span>
            }
        </>
    );
}

列表

终于把最重要的写完了~

列表呢,不过就是多行的段落罢了,我简单带大家过一遍。

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

核心正则

无序列表

unorderedList: /^\s*[-+*]\s+/,

- First item  
- Second item
  • ^:匹配输入字符串的开始位置。
  • \s*:匹配零个或多个空白字符(包括空格、制表符、换页符等等)。
  • [-+*]:匹配字符集中的任意一个字符,这里是指减号 -、加号 + 或星号 *。
  • \s+:匹配一个或多个空白字符。

有序列表

orderedList: /^\s*\d+\.\s+/,

匹配格式:

1. First item
2. Second item
  • \d+:匹配一个或多个数字字符(0-9)。
  • \.:匹配小数点 . 字符。注意,在正则表达式中,. 是一个特殊字符,表示匹配任意单个字符,所以要匹配实际的小数点字符,需要使用反斜杠 \ 进行转义。

任务列表

taskList: /^(-|\*) \[( |x)\] (.*)$/,

匹配格式:

- [x] Write the press release  
- [ ] Update the website
  • (-|\*):匹配减号 - 或星号 * 中的任意一个。
  • \[ 和 \]:分别匹配字符 [ 和 ]。由于 [ 和 ] 在正则表达式中是特殊字符,所以需要使用反斜杠 \ 进行转义。
  • ( |x):匹配一个空格 或字符 x。
  • (.*):匹配任意数量的任意字符,并捕获为一个分组。. 表示任意单个字符,* 表示零个或多个前面的字符。

解析为Token

在上面段落类型写完之后,其实最复杂的一部分已经完成了

列表的解析非常简单,它的内容也只需要将前面的标志位切割掉,再将后面的文字以段落的形式解析即可。

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

转为AST

因为我们是逐行解析,但是列表项又是多行连贯的,所以需要一个 数组的根节点(Array Root Node) 缓存每一行列表项,如果出现不连贯的时候,则清空缓存,表示当前列表结束。

设置一个currentList对象

let currentList: ParseAST | null;

而它的子节点则为一个段落节点

ok,段落节点写完,一切就是这么简单

case MarkdownElement.ListItem:
    // 当遇到列表的时候,需要创建列表的父元素
    if (!currentList || !Object.prototype.hasOwnProperty.call(currentList, 'ordered')) {
        currentList = {
            type: token.isOrdered ? MarkdownElement.OrderedList : MarkdownElement.UnorderedList,
            children: [],
            ordered: token.isOrdered
        };
        ast.children?.push(currentList)
    }

    // 接下来将当前的列表推入到列表数组中
    currentList.children?.push(parseParagraph({
        ...token,
        type: MarkdownElement.Paragraph,
    }));
    break;

并且,你需要在其他的case里面加入

...
currentList = null;
...

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

关联代码截图:

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

渲染

因为在Antd中,ol和ul可以被Paragraph包裹,有特殊样式,所以我们写起来也很简单:

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

你尽管写标签,其余的交给ParagraphContent

/** 生成列表 */
function ListItem({ node }: Props) {
    const child = node.children?.map((item, index) => {
        return <li key={index}><ParagraphContent keyValue={index + 'key'} node={item} /></li>
    });
    return (
        <>
            {
                node.ordered
                    ? <ol>{child}</ol>
                    : <ul>{child}</ul>
            }
        </>
    )
}

/** 任务列表 */
function TaskList({ node }: Props) {
    const child = node.children?.map((item, index) => (
        <Col key={index} span={24}>
            <Checkbox value={node.content} disabled={item.checked} checked={item.checked}>
                <ParagraphContent keyValue={index + 'key'} node={item} />
            </Checkbox>
        </Col>
    ));
    return <div className='mb-3'><Row>{child}</Row></div>
}

引用、图片、分割线

终于到最后哪些不是很重要的东西了,再坚持一下,快看完了。

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

核心正则

引用

blockquote: /^>\s*.*$/

匹配格式:> blockquote

  • ^:这个符号表示匹配字符串的开始位置。在正则表达式中,它用来确保后面的模式从字符串的起始位置开始匹配。
  • >:这个符号直接匹配文本中的 > 字符,这是Markdown中块引用的标志。
  • \s*\s 是一个元字符,代表任何空白字符,包括空格、制表符、换行符等。星号 * 表示前面的元素(在这里是 \s)可以出现零次或多次。因此,\s* 匹配零个或多个连续的空白字符。
  • .*:点号 . 代表除了换行符之外的任何单个字符。星号 * 表示前面的元素(在这里是 .)可以出现零次或多次。因此,.* 匹配零个或多个任意字符(直到行尾或遇到换行符)。
  • $:这个符号表示匹配字符串的结束位置。确保前面的模式匹配到字符串的末尾。

图片

image: /!\[(.*?)\]\((.*?)\)/g

匹配格式:![alt text](image.jpg)

  • !\[:匹配Markdown图片语法开始的![部分。
  • (.*?):这是一个非贪婪的捕获组。.代表匹配除了换行符以外的任意单个字符,*表示前面的字符(.)可以出现0次或多次。问号?使得这个匹配变得非贪婪(也就是尽可能少地匹配字符)。这个捕获组用于捕获图片的替代文本(alt text)。
  • \]:匹配Markdown图片语法中的闭合方括号]
  • \(:匹配Markdown图片语法中的开括号(。在正则表达式中,圆括号()是特殊字符,用于捕获组,所以需要使用反斜杠\进行转义。
  • (.*?):同样是一个非贪婪的捕获组,用于捕获图片的URL。
  • \):匹配Markdown图片语法中的闭括号)
  • /g:这是一个全局修饰符,表示正则表达式会匹配输入字符串中的所有可能的匹配项,而不仅仅是第一个。

分割线

其实他可以支持很多种/^(-{3,}|*{3,}|_{3,})$/,暂时先只支持---

horizontalRule: /^(-{3,})$/

匹配格式:---

  • (-{3,})
    • -:匹配字符 -
    • {3,}:表示前面的字符(在这里是 -)必须连续出现至少3次。{3,} 是一个量词,表示前面的元素可以出现3次或更多次。

解析为Token

这几个我们简单过一下就行,和前面的内容基本一致

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

转为AST

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

渲染

分割线用的Antd的Divider组件,图片用的Image组件(很好用哦)。

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

总结

至此我们所有的基本需求都已经开发完成,剩下的高级需求(表格、代码块)我会再发一篇文章来讲解。

本篇我们看到了Markdown转换过程中基础语法代码效果,从0到1完成了简单的Markdown编辑器,Easy!

藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)

转载自:https://juejin.cn/post/7352079468506415140
评论
请登录