基于monaco-editor、mdx和tailwindcss实现一个博客编辑器

看了vue3的playground站点,就很好奇他的原理是什么,发现它使用了monaco-editor-core这个库,这是vs code的编辑器核心,但是monaco的文档实在太烂了,资料都不知道怎么查,还好github的搜索功能足够强大

在react生态中有一个@monaco-editor/react库,看npm上用的人还是挺多的,我就使用@monaco-editor/react、mdx和tailwind实现了自己的博客编辑器

1、渲染mdx

为了让渲染出的内容较为好看,直接使用了tailwind的typography插件

css 复制代码
// file: tailwind.config.ts
import { type Config } from 'tailwindcss'
import typography from '@tailwindcss/typography'
const config: Config = {
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    // ...
    extend: {
      typography: {
        DEFAULT: {
          css: {
            // 可以通过css再次自定义typography的样式
            'p > code, h3 > code, li > code': {
              margin: '0px 4px',
              padding: '2px 4px',
              borderRadius: '6px',
              backgroundColor: '#fff',
              border: '1px solid',
              color: 'rgba(74, 222, 128,1) !important',
              '&::before': {
                display: 'none',
              },
              '&::after': {
                display: 'none',
              },
            },           
          },
        },
      },
    },
    // 代码块高亮的主题 
    hljs: {
      theme: 'atom-one-dark',
    },
  },
  plugins: [
    // 排版
    require('@tailwindcss/typography'),
    // 代码块高亮的主题
    require('tailwind-highlightjs')
  ],
}
export default config

在新增一个Blog组件中新增一个类名prose,typography会对这个类名下的jsx进行生效

tsx 复制代码
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote'
import { useEffect, useState } from 'react'
import { MDXComponents } from 'mdx/types'
import { MDXRemoteProps } from 'next-mdx-remote/rsc'
import rehypeHighlight from 'rehype-highlight'
import rehypeMdxCodeProps from 'rehype-mdx-code-props'
import CodeBlock from './codeBlock'
type BlogSource = MDXRemoteSerializeResult<
  Record<string, unknown>,
  Record<string, unknown>
>
export default function Blog({ content }: { content: string }) {
  const [blogSource, setBlogSource] = useState<BlogSource | null>(null)
  async function renderMdx(value: string | undefined) {
    try {
      const mdxSource = await serialize(value!, {
        mdxOptions: {
          ...mdxOptions,
          development: process.env.NODE_ENV === 'development',
        },
      })
      setBlogSource(mdxSource)
    } catch (e) {}
  }
  useEffect(() => {
    renderMdx(content)
  }, [content])
  return (
    <div className='prose max-w-full'>
      {blogSource && <MDXRemote {...blogSource} components={components} />}
    </div>
  )
}

const components: MDXComponents = {
  h1: ({ children }: any) => <h1 id={generateId(children)}>{children}</h1>,
  h2: ({ children }: any) => <h2 id={generateId(children)}>{children}</h2>,
  pre: (props: any) => {
    // 扩展代码块 使用下文的rehypeMdxCodeProps插件实现
    const { children, filename } = props as any
    return <CodeBlock filename={filename}>{children}</CodeBlock>
  },
}

function generateId(children: React.ReactNode) {
  return children as string
}
type MdxOptions = NonNullable<MDXRemoteProps['options']>['mdxOptions']

export const mdxOptions: MdxOptions = {
  rehypePlugins: [
    [
      // 代码块高亮
      rehypeHighlight,
    ],
    // 代码块自定义属性
    rehypeMdxCodeProps as any,
  ],
}

扩展代码块,支持文件名显示和copy功能

tsx 复制代码
'use client'
import { useRef, useState } from 'react'
import Icon from '../icon/Icon'

type Props = {
  filename: string
  children: React.ReactNode
}
export default function CodeBlock({ filename, children }: Props) {
  const ref = useRef<HTMLDivElement>(null)
  const [copied, setCopied] = useState(false)
  function copyHandle() {
    setCopied(true)
    navigator.clipboard.writeText(ref.current!.textContent!)
    window.setTimeout(() => {
      setCopied(false)
    }, 2000)
  }
  return (
    <div className='relative'>
      {filename && (
        <div className='absolute left-6 top-2 cursor-pointer rounded p-1 text-xs italic text-[#abb2bf]'>
          <span className='mr-2'>filename:</span>
          {filename}
        </div>
      )}
      <div className=' absolute right-2 top-2 text-xs italic text-[#abb2bf]'>
        {getCodeLanguage(children)}
      </div>
      <div
        className='absolute bottom-2 right-2 cursor-pointer rounded bg-white p-1'
        onClick={copyHandle}
      >
        {!copied ? (
          <Icon type='copy' />
        ) : (
          <div className='relative'>
            <div className='absolute -left-16'>
              <div className='rounded bg-white px-1 text-xs italic text-green-400'>
                Copied!
              </div>
            </div>
            <Icon type='check' />
          </div>
        )}
      </div>
      <pre className={`${filename ?? 'pt-4'}`}>
        <div ref={ref}>{children}</div>
      </pre>
    </div>
  )
}
function getCodeLanguage(children: any) {
  if (!children.props.className) return ''
  const [_, language] = children.props.className?.split('language-')
  return language as string
}

2、使用monaco在线编辑markdown

monaco又使用了prettier对markdown进行格式化,这里参考了prettier的playground的插件配置,并且通过url的hash持久化编辑的内容

tsx 复制代码
'use client'
import Editor from '@monaco-editor/react'
import { useRef, useState, useEffect } from 'react'
import { type editor } from 'monaco-editor'
import * as monacoInstance from 'monaco-editor/esm/vs/editor/editor.api'
// 使用其独立版本在浏览器中运行 Prettier 
import prettier from 'prettier/standalone'
import prettierrc from '../.prettierrc.json'
import { useToggleTheme } from '@/hooks/useToggleTheme'
type Monaco = typeof monacoInstance

interface Props {
  width: string | number
  onBlogContentChange: (content:string) => void
}
export function BlogEditor({ width, onBlogContentChange }: Props) {
  const [defaultValue, setDefaultValue] = useState('')
  const [blogContent, setBlogContent] = useState('')
  const monacoRef = useRef<Monaco | null>(null)
  const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
  const { theme: mode } = useToggleTheme()

  useEffect(() => {
    const hash = location.hash
    if (hash.indexOf('#') === 0) {
      const code = decodeURIComponent(escape(atob(hash.slice(1))))
      setDefaultValue(code)
      onBlogContentChange(code)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  function handleEditorWillMount(monaco: Monaco) {
    // monaco 代码格式化
    monaco.languages.registerDocumentFormattingEditProvider('markdown', {
      async provideDocumentFormattingEdits(model) {
        const code = model.getValue()
        const plugins = (await Promise.all([
          // import('prettier/plugins/acorn'),
          import('prettier/plugins/babel'),
          import('prettier/plugins/estree'),
          import('prettier/plugins/html'),
          import('prettier/plugins/markdown'),
          import('prettier/plugins/postcss'),
          import('prettier/plugins/typescript'),
          import('prettier/plugins/yaml'),
          
          // 这个没有对应的浏览器版本,在线的monaco编辑器,无法使用这个tailwindcss插件
          // import('prettier-plugin-tailwindcss'),
          import('prettier/parser-markdown'),
        ])) as any[]
        const text = await prettier.format(code, {
          ...(prettierrc as any),
          plugins,
          parser: 'mdx',
        })
        return [
          {
            range: model.getFullModelRange(),
            text,
          },
        ]
      },
    })
  }

  function handleEditorDidMount(
    editor: editor.IStandaloneCodeEditor,
    monaco: Monaco
  ) {
    monacoRef.current = monaco
    editorRef.current = editor
  }
  // ctrl+s 执行format
  function formatConent(e: KeyboardEvent) {
    if ((e.ctrlKey || e.metaKey) && e.key === 's') {
      if (editorRef.current) {
        editorRef.current.trigger(
          blogContent,
          'editor.action.formatDocument',
          {}
        )
      }
      e.preventDefault()
    }
  }
  useEffect(() => {
    window.addEventListener('keydown', formatConent)
    return () => {
      window.removeEventListener('keydown', formatConent)
    }
  }, [])
  return (
    <Editor
      width={width}
      defaultLanguage='markdown'
      defaultValue={defaultValue}
      beforeMount={handleEditorWillMount}
      onMount={handleEditorDidMount}
      options={{
        minimap: {
          enabled: false,
        },
      }}
      theme={mode === 'light' ? 'light' : 'vs-dark'}
      onChange={(value) => {
        // 持久化
        const base64 = btoa(unescape(encodeURIComponent(value!)))
        const url = 'editor#' + base64
        history.replaceState({}, '', url)
        setBlogContent(value!)
        onBlogContentChange(value!)
      }}
    />
  )
}

3、案例demo代码和演示效果

tsx 复制代码
'use client'
import Blog from '@/components/blog'
import { BlogEditor } from '@/components/blogEditor'
import Header from '@/components/layout/header'
import { mdxOptions } from '@/components/mdx/mdxConfig'
import { MdxDisplay } from '@/components/mdx/mdxDisplay'
import { type MDXRemoteSerializeResult } from 'next-mdx-remote'
import { serialize } from 'next-mdx-remote/serialize'
import { useState } from 'react'

type BlogSource = MDXRemoteSerializeResult<
  Record<string, unknown>,
  Record<string, unknown>
>
export default function Page() {
  const [blogSource, setBlogSource] = useState<BlogSource | null>(null)
  const [error, setError] = useState(false)
  function onBlogContentChange(content:string){
    setBlogSource(content)
  }
  return (
    <div className='flex h-screen w-screen flex-col'>
      <Header></Header>
      <div className='mt-[60px] flex h-0 flex-1'>
        <BlogEditor width='50%' onBlogContentChange={onBlogContentChange} />
        <div className='flex-1 overflow-auto p-4'>
          {error ? (
            <div className='rounded bg-gray-200 p-4 text-red-500'>
              🚨内容格式错误!
            </div>
          ) : (
            <Blog content={blogSource}></Blog>
          )}
        </div>
      </div>
    </div>
  )
}
相关推荐
前端大卫25 分钟前
Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】
前端
却尘41 分钟前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare42 分钟前
浅浅看一下设计模式
前端
Lee川1 小时前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix1 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人1 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl1 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人2 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼2 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端