feat: use mdxeditor for document editor (#118)

* feat: use mdxeditor for document editor

* document edior example

* fix: format

* update document about importing editor.css for document editor

* Create lemon-papayas-attend.md

* add note about dynamic import

* missing css in doc
This commit is contained in:
Thuc Pham
2025-06-05 09:55:35 +07:00
committed by GitHub
parent e740d77b15
commit a34688aa16
17 changed files with 4030 additions and 356 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@llamaindex/chat-ui': patch
---
feat: use mdxeditor for document editor
+1
View File
@@ -186,6 +186,7 @@ If your app is using code, latex or pdf files, you'll need to import their CSS f
```tsx
import '@llamaindex/chat-ui/styles/markdown.css' // code, latex and custom markdown styling
import '@llamaindex/chat-ui/styles/pdf.css' // pdf styling
import '@llamaindex/chat-ui/styles/editor.css' // document editor styling
```
The `code.css` file uses the `atom-one-dark` theme from highlight.js by default. There are a lot of others to choose from: https://highlightjs.org/demo
@@ -0,0 +1,48 @@
export const markdown = `
# Welcome
This is a **live demo** of MDXEditor with all default features on.
[— Daring Fireball](https://daringfireball.net/projects/markdown/).
In here, you can find the following markdown elements:
* Headings
* Lists
* Unordered
* Ordered
* Check lists
* And nested ;)
* Links
* Bold/Italic/Underline formatting
* Tables
* Code block editors
* And much more.
The current editor content is styled using the \`@tailwindcss/typography\` [plugin](https://tailwindcss.com/docs/typography-plugin).
## What can you do here?
This is a great location for you to test how editing markdown feels. If you have an existing markdown source, you can switch to source mode using the toggle group in the top right, paste it in there, and go back to rich text mode.
If you need a few ideas, here's what you can try:
1. Add your own code sample
2. Change the type of the headings
3. Insert a table, add a few rows and columns
4. Switch back to source markdown to see what you're going to get as an output
5. Test the diff feature to see how the markdown has changed
6. Add a frontmatter block through the toolbar button
## A table
Play with the table below - add rows, columns, change column alignment. When editing,
you can navigate the cells with \`enter\`, \`shift+enter\`, \`tab\` and \`shift+tab\`.
| Item | In Stock | Price | Color | Size | Rating | Reviews |
| :---------------- | :------: | ----: | :------- | :--: | -----: | ------: |
| Python Hat | True | 23.99 | Blue | M | 4.5 | 128 |
| SQL Hat | True | 23.99 | Black | L | 4.3 | 95 |
| Codecademy Tee | False | 19.99 | Gray | XL | 4.8 | 256 |
| Codecademy Hoodie | False | 42.99 | Navy | M | 4.7 | 312 |
`
@@ -0,0 +1,19 @@
'use client'
import dynamic from 'next/dynamic'
import { markdown } from './data'
const DocumentEditor = dynamic(
() => import('@llamaindex/chat-ui/widgets').then(mod => mod.DocumentEditor),
{
ssr: false,
}
)
export default function Home() {
return (
<div className="mx-auto h-screen w-1/2 py-4">
<DocumentEditor content={markdown} onChange={console.log} />
</div>
)
}
+1
View File
@@ -1,4 +1,5 @@
import './globals.css'
import '@llamaindex/chat-ui/styles/editor.css'
import '@llamaindex/chat-ui/styles/markdown.css'
import '@llamaindex/chat-ui/styles/pdf.css'
import type { Metadata } from 'next'
+1 -1
View File
@@ -11,7 +11,7 @@
"files": [
{
"path": "registry/chat/chat.tsx",
"content": "'use client'\r\n\r\nimport {\r\n ChatHandler,\r\n ChatSection as ChatSectionUI,\r\n Message,\r\n} from '@llamaindex/chat-ui'\r\n\r\nimport '@llamaindex/chat-ui/styles/markdown.css'\r\nimport '@llamaindex/chat-ui/styles/pdf.css'\r\nimport { useState } from 'react'\r\n\r\nconst initialMessages: Message[] = [\r\n {\r\n content: 'Write simple Javascript hello world code',\r\n role: 'user',\r\n },\r\n {\r\n role: 'assistant',\r\n content:\r\n 'Got it! Here\\'s the simplest JavaScript code to print \"Hello, World!\" to the console:\\n\\n```javascript\\nconsole.log(\"Hello, World!\");\\n```\\n\\nYou can run this code in any JavaScript environment, such as a web browser\\'s console or a Node.js environment. Just paste the code and execute it to see the output.',\r\n },\r\n {\r\n content: 'Write a simple math equation',\r\n role: 'user',\r\n },\r\n {\r\n role: 'assistant',\r\n content:\r\n \"Let's explore a simple mathematical equation using LaTeX:\\n\\n The quadratic formula is: $$x = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}$$\\n\\nThis formula helps us solve quadratic equations in the form $ax^2 + bx + c = 0$. The solution gives us the x-values where the parabola intersects the x-axis.\",\r\n },\r\n]\r\n\r\nexport function ChatSection() {\r\n // You can replace the handler with a useChat hook from Vercel AI SDK\r\n const handler = useMockChat(initialMessages)\r\n return (\r\n <div className=\"flex max-h-[80vh] flex-col gap-6 overflow-y-auto\">\r\n <ChatSectionUI handler={handler} />\r\n </div>\r\n )\r\n}\r\n\r\nfunction useMockChat(initMessages: Message[]): ChatHandler {\r\n const [messages, setMessages] = useState<Message[]>(initMessages)\r\n const [input, setInput] = useState('')\r\n const [isLoading, setIsLoading] = useState(false)\r\n\r\n const append = async (message: Message) => {\r\n setIsLoading(true)\r\n\r\n const mockResponse: Message = {\r\n role: 'assistant',\r\n content: '',\r\n }\r\n setMessages(prev => [...prev, message, mockResponse])\r\n\r\n const mockContent =\r\n 'This is a mock response. In a real implementation, this would be replaced with an actual AI response.'\r\n\r\n let streamedContent = ''\r\n const words = mockContent.split(' ')\r\n\r\n for (const word of words) {\r\n await new Promise(resolve => setTimeout(resolve, 100))\r\n streamedContent += (streamedContent ? ' ' : '') + word\r\n setMessages(prev => {\r\n return [\r\n ...prev.slice(0, -1),\r\n {\r\n role: 'assistant',\r\n content: streamedContent,\r\n },\r\n ]\r\n })\r\n }\r\n\r\n setIsLoading(false)\r\n return mockContent\r\n }\r\n\r\n return {\r\n messages,\r\n input,\r\n setInput,\r\n isLoading,\r\n append,\r\n }\r\n}\r\n",
"content": "'use client'\r\n\r\nimport {\r\n ChatHandler,\r\n ChatSection as ChatSectionUI,\r\n Message,\r\n} from '@llamaindex/chat-ui'\r\n\r\nimport '@llamaindex/chat-ui/styles/markdown.css'\r\nimport '@llamaindex/chat-ui/styles/pdf.css'\r\nimport '@llamaindex/chat-ui/styles/editor.css'\r\nimport { useState } from 'react'\r\n\r\nconst initialMessages: Message[] = [\r\n {\r\n content: 'Write simple Javascript hello world code',\r\n role: 'user',\r\n },\r\n {\r\n role: 'assistant',\r\n content:\r\n 'Got it! Here\\'s the simplest JavaScript code to print \"Hello, World!\" to the console:\\n\\n```javascript\\nconsole.log(\"Hello, World!\");\\n```\\n\\nYou can run this code in any JavaScript environment, such as a web browser\\'s console or a Node.js environment. Just paste the code and execute it to see the output.',\r\n },\r\n {\r\n content: 'Write a simple math equation',\r\n role: 'user',\r\n },\r\n {\r\n role: 'assistant',\r\n content:\r\n \"Let's explore a simple mathematical equation using LaTeX:\\n\\n The quadratic formula is: $$x = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}$$\\n\\nThis formula helps us solve quadratic equations in the form $ax^2 + bx + c = 0$. The solution gives us the x-values where the parabola intersects the x-axis.\",\r\n },\r\n]\r\n\r\nexport function ChatSection() {\r\n // You can replace the handler with a useChat hook from Vercel AI SDK\r\n const handler = useMockChat(initialMessages)\r\n return (\r\n <div className=\"flex max-h-[80vh] flex-col gap-6 overflow-y-auto\">\r\n <ChatSectionUI handler={handler} />\r\n </div>\r\n )\r\n}\r\n\r\nfunction useMockChat(initMessages: Message[]): ChatHandler {\r\n const [messages, setMessages] = useState<Message[]>(initMessages)\r\n const [input, setInput] = useState('')\r\n const [isLoading, setIsLoading] = useState(false)\r\n\r\n const append = async (message: Message) => {\r\n setIsLoading(true)\r\n\r\n const mockResponse: Message = {\r\n role: 'assistant',\r\n content: '',\r\n }\r\n setMessages(prev => [...prev, message, mockResponse])\r\n\r\n const mockContent =\r\n 'This is a mock response. In a real implementation, this would be replaced with an actual AI response.'\r\n\r\n let streamedContent = ''\r\n const words = mockContent.split(' ')\r\n\r\n for (const word of words) {\r\n await new Promise(resolve => setTimeout(resolve, 100))\r\n streamedContent += (streamedContent ? ' ' : '') + word\r\n setMessages(prev => {\r\n return [\r\n ...prev.slice(0, -1),\r\n {\r\n role: 'assistant',\r\n content: streamedContent,\r\n },\r\n ]\r\n })\r\n }\r\n\r\n setIsLoading(false)\r\n return mockContent\r\n }\r\n\r\n return {\r\n messages,\r\n input,\r\n setInput,\r\n isLoading,\r\n append,\r\n }\r\n}\r\n",
"type": "registry:block"
}
],
+1
View File
@@ -8,6 +8,7 @@ import {
import '@llamaindex/chat-ui/styles/markdown.css'
import '@llamaindex/chat-ui/styles/pdf.css'
import '@llamaindex/chat-ui/styles/editor.css'
import { useState } from 'react'
const initialMessages: Message[] = [
+1
View File
@@ -112,6 +112,7 @@ Import the CSS styles in your app's root file (e.g., `_app.tsx` or `layout.tsx`)
```tsx
import '@llamaindex/chat-ui/styles/markdown.css' // code, latex and custom markdown styling
import '@llamaindex/chat-ui/styles/pdf.css' // pdf styling
import '@llamaindex/chat-ui/styles/editor.css' // document editor styling
```
The `markdown.css` file includes styling for code blocks using [highlight.js](https://highlightjs.org/) with the `atom-one-dark` theme by default, [katex](https://katex.org/) for latex, and [pdf-viewer](https://github.com/run-llama/pdf-viewer) for PDF files. You can use any highlight.js theme by copying [their CSS](https://github.com/highlightjs/highlight.js/tree/main/src/styles/) to your project and importing it.
+24 -4
View File
@@ -110,23 +110,43 @@ function InteractiveCode({ initialCode, language, onChange }) {
### DocumentEditor
Rich text document editor with markdown support.
Rich text document editor with markdown support. Please import editor styles from `@llamaindex/chat-ui/styles/editor.css` to ensure proper styling.
```tsx
import { DocumentEditor } from '@llamaindex/chat-ui/widgets'
import '@llamaindex/chat-ui/styles/editor.css'
function DocumentEdit({ content, onChange, title }) {
return (
<DocumentEditor
value={content}
content={content}
onChange={onChange}
title={title}
placeholder="Start writing..."
className="custom-css-class"
/>
)
}
```
**Note:** When using Next.js App Router, you need to dynamically import this component to avoid SSR issues:
```tsx
'use client'
import dynamic from 'next/dynamic'
import '@llamaindex/chat-ui/styles/editor.css'
const DocumentEditor = dynamic(
() => import('@llamaindex/chat-ui/widgets').then(mod => mod.DocumentEditor),
{ ssr: false }
)
export default function Home() {
return (
<DocumentEditor content={"# Hello World"} onChange={console.log} />
)
}
```
**Features:**
- **WYSIWYG Editing** - Visual editing with markdown output
+1
View File
@@ -1,6 +1,7 @@
import './globals.css'
import '@llamaindex/chat-ui/styles/markdown.css'
import '@llamaindex/chat-ui/styles/pdf.css'
import '@llamaindex/chat-ui/styles/editor.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
+1
View File
@@ -1,6 +1,7 @@
import './globals.css'
import '@llamaindex/chat-ui/styles/markdown.css'
import '@llamaindex/chat-ui/styles/pdf.css'
import '@llamaindex/chat-ui/styles/editor.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
+1 -1
View File
@@ -64,7 +64,7 @@ Located in `src/widgets/` and exported via `/widgets` entry point:
- **Markdown**: `react-markdown` with math support (`katex`, `remark-math`, `rehype-katex`)
- **Code Editing**: CodeMirror 6 with language support (JavaScript, Python, CSS, HTML)
- **Syntax Highlighting**: `highlight.js`
- **Document Editing**: ProseMirror with markdown support
- **Document Editing**: Mdxeditor with markdown support
- **PDF Viewing**: `@llamaindex/pdf-viewer`
### Styling & UI
+1 -8
View File
@@ -71,14 +71,7 @@
"highlight.js": "^11.10.0",
"katex": "^0.16.21",
"lucide-react": "^0.453.0",
"prosemirror-commands": "^1.7.1",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.2",
"prosemirror-menu": "^1.2.5",
"prosemirror-schema-basic": "^1.2.4",
"prosemirror-state": "^1.4.3",
"prosemirror-view": "^1.39.2",
"@mdxeditor/editor": "^3.35.0",
"react-markdown": "^8.0.7",
"rehype-katex": "^7.0.0",
"remark": "^15.0.1",
File diff suppressed because it is too large Load Diff
-7
View File
@@ -146,10 +146,3 @@ pre:has(.custom-renderer) {
font-variation-settings: inherit;
font-size: inherit;
}
/*
* Custom CSS for code editor
*/
.ProseMirror:focus-visible {
outline: none;
}
+40 -176
View File
@@ -1,129 +1,16 @@
import { baseKeymap, toggleMark } from 'prosemirror-commands'
import { history, redo, undo } from 'prosemirror-history'
import { keymap } from 'prosemirror-keymap'
import {
defaultMarkdownParser as markdownParser,
defaultMarkdownSerializer as markdownSerializer,
} from 'prosemirror-markdown'
import { schema as basicSchema } from 'prosemirror-schema-basic'
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { useEffect, useRef, useState } from 'react'
import { cn } from '../lib/utils'
import { Button } from '../ui/button'
import { Bold, Italic, Code, Link, Undo, Redo } from 'lucide-react'
interface ToolbarProps {
view: EditorView | null
}
const Toolbar: React.FC<ToolbarProps> = ({ view }) => {
const handleBold = () => {
if (view) {
toggleMark(basicSchema.marks.strong)(view.state, view.dispatch)
view.focus()
}
}
const handleItalic = () => {
if (view) {
toggleMark(basicSchema.marks.em)(view.state, view.dispatch)
view.focus()
}
}
const handleCode = () => {
if (view) {
toggleMark(basicSchema.marks.code)(view.state, view.dispatch)
view.focus()
}
}
const handleLink = () => {
if (view) {
const { state, dispatch } = view
if (state.selection.empty) return
const href = prompt('Enter the URL', 'https://')
if (href) {
toggleMark(basicSchema.marks.link, { href })(state, dispatch)
view.focus()
}
}
}
const handleUndo = () => {
if (view) {
undo(view.state, view.dispatch)
view.focus()
}
}
const handleRedo = () => {
if (view) {
redo(view.state, view.dispatch)
view.focus()
}
}
return (
<div className="flex shrink-0 gap-2">
<Button
onClick={handleUndo}
variant="outline"
size="icon"
title="Undo (Ctrl+Z)"
className="size-7"
>
<Undo className="size-4" />
</Button>
<Button
onClick={handleRedo}
variant="outline"
size="icon"
title="Redo (Ctrl+Y)"
className="size-7"
>
<Redo className="size-4" />
</Button>
<Button
onClick={handleBold}
variant="outline"
size="icon"
title="Bold (Ctrl+B)"
className="size-7"
>
<Bold className="size-4" />
</Button>
<Button
onClick={handleItalic}
variant="outline"
size="icon"
title="Italic (Ctrl+I)"
className="size-7"
>
<Italic className="size-4" />
</Button>
<Button
onClick={handleCode}
variant="outline"
size="icon"
title="Code (Ctrl+Shift+C)"
className="size-7"
>
<Code className="size-4" />
</Button>
<Button
onClick={handleLink}
variant="outline"
size="icon"
title="Link"
className="size-7"
>
<Link className="size-4" />
</Button>
</div>
)
}
BlockTypeSelect,
BoldItalicUnderlineToggles,
MDXEditor,
UndoRedo,
headingsPlugin,
linkPlugin,
linkDialogPlugin,
listsPlugin,
markdownShortcutPlugin,
tablePlugin,
toolbarPlugin,
} from '@mdxeditor/editor'
export function DocumentEditor({
content,
@@ -136,59 +23,36 @@ export function DocumentEditor({
className?: string
showToolbar?: boolean
}) {
const editorRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const [initialized, setInitialized] = useState(false)
const plugins = [
headingsPlugin(),
listsPlugin(),
linkPlugin(),
linkDialogPlugin(),
tablePlugin(),
markdownShortcutPlugin(),
]
useEffect(() => {
if (!editorRef.current || initialized) return
const state = EditorState.create({
doc:
markdownParser.parse(content) ||
basicSchema.topNodeType.createAndFill(),
schema: basicSchema,
plugins: [
history(),
keymap({
'Mod-z': undo,
'Mod-y': redo,
'Mod-b': toggleMark(basicSchema.marks.strong),
'Mod-i': toggleMark(basicSchema.marks.em),
'Mod-Shift-s': toggleMark(basicSchema.marks.strike),
'Mod-Shift-c': toggleMark(basicSchema.marks.code),
}),
keymap(baseKeymap),
],
})
const view = new EditorView(editorRef.current, {
state,
dispatchTransaction(transaction) {
const newState = view.state.apply(transaction)
view.updateState(newState)
if (onChange && transaction.docChanged) {
const markdown = markdownSerializer.serialize(newState.doc)
onChange(markdown)
}
},
})
viewRef.current = view
setInitialized(true)
return () => {
view.destroy()
viewRef.current = null
}
}, [])
if (showToolbar) {
plugins.push(
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<BlockTypeSelect />
</>
),
})
)
}
return (
<div
className={cn('custom-markdown flex h-full flex-col gap-3', className)}
>
{showToolbar && <Toolbar view={viewRef.current} />}
<div ref={editorRef} className="min-h-0 flex-1 overflow-auto" />
</div>
<MDXEditor
className={className}
onChange={onChange}
markdown={content}
plugins={plugins}
contentEditableClassName="custom-markdown"
/>
)
}
+1502 -159
View File
File diff suppressed because it is too large Load Diff