Skip to content

Markdown

ProseKit doesn't ship a Markdown serializer because different teams want different Markdown flavors, and ProseMirror's HTML representation is already lossless for the document tree. The recommended path is to serialize to HTML first, then convert HTML ↔ Markdown with remark and rehype.

'use client'

import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'

import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, jsonFromHTML } from 'prosekit/core'
import { ProseKit, useDocChange } from 'prosekit/react'
import { useCallback, useMemo, useState } from 'react'

import { htmlFromMarkdown, markdownFromHTML } from './markdown'

export default function Editor() {
  // A list of saved documents, stored as Markdown strings
  const [records, setRecords] = useState<string[]>([])
  // Whether there are unsaved changes
  const [hasUnsavedChange, setHasUnsavedChange] = useState(false)
  // A key to force a re-render of the editor
  const [key, setKey] = useState(1)

  const editor = useMemo(() => {
    const extension = defineBasicExtension()
    return createEditor({ extension })
  }, [])

  const handleDocChange = useCallback(() => setHasUnsavedChange(true), [])
  useDocChange(handleDocChange, { editor })

  const handleSave = useCallback(() => {
    const html = editor.getDocHTML()
    const record = markdownFromHTML(html)
    setRecords((prev) => [...prev, record])
    setHasUnsavedChange(false)
  }, [editor])

  const handleLoad = useCallback(
    (record: string) => {
      const html = htmlFromMarkdown(record)
      editor.setContent(jsonFromHTML(html, { schema: editor.schema }))
      setHasUnsavedChange(false)
      setKey((prev) => prev + 1)
    },
    [editor],
  )

  return (
    <div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
      <button
        onClick={handleSave}
        disabled={!hasUnsavedChange}
        className="m-1 border border-solid bg-white px-2 py-1 text-sm text-black disabled:cursor-not-allowed disabled:text-gray-500"
      >
        {hasUnsavedChange ? 'Save' : 'No changes to save'}
      </button>
      <ul className="border-b border-t border-solid text-sm">
        {records.map((record, index) => (
          <li key={index} className="m-1 flex gap-2">
            <button
              className="border border-solid bg-white px-2 py-1 text-black"
              onClick={() => handleLoad(record)}
            >
              Load
            </button>
            <span className="flex-1 overflow-x-scroll p-2">
              <pre>{record}</pre>
            </span>
          </li>
        ))}
      </ul>
      <ProseKit editor={editor} key={key}>
        <div className="relative w-full flex-1 box-border overflow-y-auto">
          <div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
        </div>
      </ProseKit>
    </div>
  )
}
import rehypeParse from 'rehype-parse'
import rehypeRemark from 'rehype-remark'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'

export async function htmlToMarkdown(html: string): Promise<string> {
  const file = await unified()
    .use(rehypeParse, { fragment: true })
    .use(rehypeRemark)
    .use(remarkStringify)
    .process(html)
  return String(file)
}
import rehypeStringify from 'rehype-stringify'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import { unified } from 'unified'

export async function markdownToHtml(markdown: string): Promise<string> {
  const file = await unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypeStringify)
    .process(markdown)
  return String(file)
}

// Save
async function savefunction save(): Promise<void>() {
  const htmlconst html: string = editorconst editor: Editor<BasicExtension>.getDocHTMLEditor<BasicExtension>.getDocHTML: (options?: getDocHTMLOptions) => string
Return an HTML string representing the editor's current document.
()
const mdconst md: string = await htmlToMarkdownfunction htmlToMarkdown(html: string): Promise<string>(htmlconst html: string) localStoragevar localStorage: Storage
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/localStorage)
.setItemStorage.setItem(key: string, value: string): void
The **`setItem()`** method of the Storage interface, when passed a key name and value, will add that key to the given Storage object, or update that key's value if it already exists. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/setItem)
('doc.md', mdconst md: string)
} // Load async function loadfunction load(): Promise<void>() { const mdconst md: string = localStoragevar localStorage: Storage
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/localStorage)
.getItemStorage.getItem(key: string): string | null
The **`getItem()`** method of the Storage interface, when passed a key name, will return that key's value, or null if the key does not exist, in the given Storage object. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/getItem)
('doc.md') ?? ''
const htmlconst html: string = await markdownToHtmlfunction markdownToHtml(md: string): Promise<string>(mdconst md: string) editorconst editor: Editor<BasicExtension>.setContentEditor<BasicExtension>.setContent: (content: Node | NodeJSON | string | Element, selection?: SelectionJSON | Selection | "start" | "end") => void
Update the editor's document and selection.
@paramcontent - The new document to set. It can be one of the following: - A ProseMirror node instance - A ProseMirror node JSON object - An HTML string - A DOM element instance@paramselection - Optional. Specifies the new selection. It can be one of the following: - A ProseMirror selection instance - A ProseMirror selection JSON object - The string "start" (to set selection at the beginning, default value) - The string "end" (to set selection at the end)
(htmlconst html: string)
}