看了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>
)
}