基于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>
  )
}
相关推荐
brrdg_sefg2 分钟前
Rust 在前端基建中的使用
前端·rust·状态模式
m0_7482309427 分钟前
Rust赋能前端: 纯血前端将 Table 导出 Excel
前端·rust·excel
qq_5895681035 分钟前
Echarts的高级使用,动画,交互api
前端·javascript·echarts
黑客老陈2 小时前
新手小白如何挖掘cnvd通用漏洞之存储xss漏洞(利用xss钓鱼)
运维·服务器·前端·网络·安全·web3·xss
正小安2 小时前
Vite系列课程 | 11. Vite 配置文件中 CSS 配置(Modules 模块化篇)
前端·vite
暴富的Tdy2 小时前
【CryptoJS库AES加密】
前端·javascript·vue.js
neeef_se2 小时前
Vue中使用a标签下载静态资源文件(比如excel、pdf等),纯前端操作
前端·vue.js·excel
m0_748235612 小时前
web 渗透学习指南——初学者防入狱篇
前端