project-nomad/admin/inertia/components/MarkdownEditor.tsx

73 lines
2.4 KiB
TypeScript

import { useEffect, useRef } from 'react'
import { Compartment, EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { markdown } from '@codemirror/lang-markdown'
import { oneDark } from '@codemirror/theme-one-dark'
import { basicSetup } from 'codemirror'
import { useTheme } from '~/hooks/useTheme'
interface MarkdownEditorProps {
/** Initial document contents. Read once on mount; later edits are reported via onChange. */
initialValue: string
onChange: (value: string) => void
className?: string
}
/**
* A thin React wrapper that mounts a CodeMirror 6 editor tuned for Markdown.
* The editor is uncontrolled after mount (its state lives in CodeMirror); the
* current value is surfaced to the parent through onChange. The one-dark theme
* is applied via a Compartment so light/dark toggles reconfigure in place
* without rebuilding the document.
*/
export default function MarkdownEditor({ initialValue, onChange, className }: MarkdownEditorProps) {
const hostRef = useRef<HTMLDivElement | null>(null)
const viewRef = useRef<EditorView | null>(null)
const themeCompartment = useRef(new Compartment())
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const { theme } = useTheme()
useEffect(() => {
if (!hostRef.current) return
const state = EditorState.create({
doc: initialValue,
extensions: [
basicSetup,
markdown(),
EditorView.lineWrapping,
// Fill the host element and scroll internally rather than growing the page.
EditorView.theme({
'&': { height: '100%' },
'.cm-scroller': { overflow: 'auto' },
}),
themeCompartment.current.of(theme === 'dark' ? oneDark : []),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString())
}
}),
],
})
const view = new EditorView({ state, parent: hostRef.current })
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
}
// Mount once; initialValue/theme changes are handled below or by remounting.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
viewRef.current?.dispatch({
effects: themeCompartment.current.reconfigure(theme === 'dark' ? oneDark : []),
})
}, [theme])
return <div ref={hostRef} className={className} />
}