editor-markdown.web.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. 'use client'
  2. import Field, { getValidationRules } from './_field'
  3. import { useFormContext } from 'react-hook-form'
  4. import { useFieldController } from 'app/lib/form/use-form-field'
  5. import { useEffect, useMemo, useRef, useState } from 'react'
  6. import {
  7. MDXEditor,
  8. UndoRedo,
  9. BoldItalicUnderlineToggles,
  10. StrikeThroughSupSubToggles,
  11. CodeToggle,
  12. ListsToggle,
  13. BlockTypeSelect,
  14. CreateLink,
  15. Separator,
  16. headingsPlugin,
  17. listsPlugin,
  18. quotePlugin,
  19. thematicBreakPlugin,
  20. markdownShortcutPlugin,
  21. linkPlugin,
  22. linkDialogPlugin,
  23. toolbarPlugin,
  24. tablePlugin,
  25. codeBlockPlugin,
  26. codeMirrorPlugin,
  27. CodeMirrorEditor,
  28. frontmatterPlugin,
  29. diffSourcePlugin,
  30. DiffSourceToggleWrapper,
  31. realmPlugin,
  32. addImportVisitor$,
  33. } from '@mdxeditor/editor'
  34. import '@mdxeditor/editor/style.css'
  35. // After MDXEditor styles — overrides --basePageBg: white and toolbar chrome.
  36. import './editor-markdown.web.css'
  37. import { EditorView } from '@codemirror/view'
  38. import {
  39. $createParagraphNode,
  40. $createTextNode,
  41. $isRootNode,
  42. } from 'lexical'
  43. import { useThemeName } from 'app/design/theme'
  44. import { appSetting, cn } from 'app/lib/util'
  45. import emitter, { EVENTS } from 'app/context/emitter'
  46. import Loading from 'app/ui/atoms/loading'
  47. const inputSettings = appSetting('theme', 'inputs')
  48. /**
  49. * With suppressHtmlProcessing, remark still emits raw `{ type: 'html' }` nodes
  50. * (e.g. `<br/>`, `</div>`) but MdastHTMLVisitor is not registered — import fails
  51. * with `{"type":"html","name":"N/A"}`. Keep HTML as literal text instead.
  52. */
  53. const htmlAsTextPlugin = realmPlugin({
  54. init(realm) {
  55. realm.pub(addImportVisitor$, {
  56. testNode: (node) => node?.type === 'html',
  57. visitNode({ mdastNode, lexicalParent, actions }) {
  58. const value = mdastNode?.value ?? ''
  59. if (!value) return
  60. const textNode = $createTextNode(value)
  61. textNode.setFormat(actions.getParentFormatting())
  62. const style = actions.getParentStyle()
  63. if (style) textNode.setStyle(style)
  64. if ($isRootNode(lexicalParent)) {
  65. const paragraph = $createParagraphNode()
  66. paragraph.append(textNode)
  67. lexicalParent.append(paragraph)
  68. return
  69. }
  70. actions.addAndStepInto(textNode)
  71. },
  72. priority: -50,
  73. })
  74. },
  75. })
  76. /** CM6 theme — MDXEditor source/code blocks hardcode basicLight; earlier extensions win. */
  77. const neoCmDarkTheme = EditorView.theme(
  78. {
  79. '&': {
  80. backgroundColor: 'transparent',
  81. color: 'inherit',
  82. },
  83. '.cm-content': {
  84. caretColor: 'currentColor',
  85. },
  86. '&.cm-focused .cm-cursor': {
  87. borderLeftColor: 'currentColor',
  88. },
  89. '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
  90. backgroundColor: 'rgb(var(--primary) / 0.35)',
  91. },
  92. '.cm-gutters': {
  93. backgroundColor: 'transparent',
  94. color: 'rgb(var(--muted-foreground))',
  95. border: 'none',
  96. },
  97. '.cm-activeLineGutter, .cm-activeLine': {
  98. backgroundColor: 'rgb(var(--muted) / 0.25)',
  99. },
  100. },
  101. { dark: true },
  102. )
  103. /**
  104. * Wiki Markdown form field (web) — @mdxeditor/editor.
  105. * Native resolves `editor-markdown.js` (EnrichedMarkdownTextInput).
  106. */
  107. export default function FormFieldMarkdownEditor(props) {
  108. const formContext = useFormContext()
  109. const placeholder = props.use_caption_as_placeholder
  110. ? props.caption
  111. : props.placeholder
  112. const isCommentsForm = props.container_class === 'comments'
  113. const initialHeight = props.height || (isCommentsForm ? 48 : 120)
  114. const rules = useMemo(() => getValidationRules(props), [
  115. props.checker,
  116. props.required,
  117. props.name,
  118. ])
  119. const disabled =
  120. props?.attrs?.readonly == 'readonly' ||
  121. props?.attrs?.readonly == true ||
  122. props?.attrs?.disabled == 'disabled' ||
  123. !!props.disabled
  124. return (
  125. <Field {...props} error2={formContext.formState.errors[props.name]}>
  126. <MarkdownTextInput
  127. name={props.name}
  128. value={props.value}
  129. bg={props.bg}
  130. disabled={disabled}
  131. autofocus={props.autofocus}
  132. placeholder={placeholder}
  133. initialHeight={initialHeight}
  134. maxHeight={props.maxHeight || 400}
  135. showToolbar={props.showToolbar !== false}
  136. rules={rules}
  137. />
  138. </Field>
  139. )
  140. }
  141. export function MarkdownTextInput({
  142. name,
  143. value: valueProp,
  144. initialHeight = 120,
  145. maxHeight = 400,
  146. bg,
  147. placeholder,
  148. autofocus,
  149. disabled,
  150. showToolbar = true,
  151. rules = {},
  152. }) {
  153. const externalValue = valueProp ?? ''
  154. const { field } = useFieldController({
  155. name,
  156. rules,
  157. defaultValue: externalValue,
  158. })
  159. const { setValue } = useFormContext()
  160. const editorRef = useRef(null)
  161. const initialMarkdown = useRef(externalValue)
  162. const lastWrittenRef = useRef(externalValue)
  163. const [mounted, setMounted] = useState(false)
  164. const themeName = useThemeName()
  165. const isDark = themeName === 'dark'
  166. useEffect(() => {
  167. setMounted(true)
  168. }, [])
  169. useEffect(() => {
  170. if (externalValue === lastWrittenRef.current) return
  171. lastWrittenRef.current = externalValue
  172. setValue(name, externalValue)
  173. if (mounted) {
  174. editorRef.current?.setMarkdown(externalValue || '')
  175. }
  176. }, [externalValue, name, setValue, mounted])
  177. useEffect(() => {
  178. if (!mounted) return undefined
  179. const sub = emitter.addListener(EVENTS.editor, (data) => {
  180. if (!editorRef.current) return
  181. if (data.action === 'focus') {
  182. editorRef.current.focus()
  183. }
  184. if (data.action === 'set_content') {
  185. const next = data.value || ''
  186. lastWrittenRef.current = next
  187. editorRef.current.setMarkdown(next)
  188. field.onChange(next)
  189. }
  190. })
  191. return () => sub.remove()
  192. }, [field, mounted])
  193. // readOnly: edit off, scroll/select still work. Hide toolbar when locked.
  194. const toolbarVisible = showToolbar && !disabled
  195. const plugins = useMemo(() => {
  196. const cmExtensions = isDark ? [neoCmDarkTheme] : []
  197. const list = [
  198. headingsPlugin({ allowedHeadingLevels: [1, 2, 3, 4, 5, 6] }),
  199. listsPlugin(),
  200. quotePlugin(),
  201. thematicBreakPlugin(),
  202. markdownShortcutPlugin(),
  203. linkPlugin(),
  204. linkDialogPlugin(),
  205. tablePlugin(),
  206. frontmatterPlugin(),
  207. htmlAsTextPlugin(),
  208. codeBlockPlugin({
  209. defaultCodeBlockLanguage: '',
  210. // Catch-all so unknown fence languages don't fail import.
  211. codeBlockEditorDescriptors: [
  212. {
  213. priority: -10,
  214. match: () => true,
  215. Editor: CodeMirrorEditor,
  216. },
  217. ],
  218. }),
  219. codeMirrorPlugin({
  220. codeMirrorExtensions: cmExtensions,
  221. codeBlockLanguages: {
  222. '': 'Plain text',
  223. js: 'JavaScript',
  224. ts: 'TypeScript',
  225. jsx: 'JavaScript (React)',
  226. tsx: 'TypeScript (React)',
  227. css: 'CSS',
  228. html: 'HTML',
  229. json: 'JSON',
  230. md: 'Markdown',
  231. bash: 'Bash',
  232. php: 'PHP',
  233. },
  234. }),
  235. diffSourcePlugin({
  236. viewMode: 'rich-text',
  237. codeMirrorExtensions: cmExtensions,
  238. }),
  239. ]
  240. if (toolbarVisible) {
  241. list.push(
  242. toolbarPlugin({
  243. toolbarClassName: 'mdxeditor-wiki-toolbar',
  244. toolbarContents: () => (
  245. <DiffSourceToggleWrapper>
  246. <UndoRedo />
  247. <Separator />
  248. <BoldItalicUnderlineToggles />
  249. <StrikeThroughSupSubToggles options={['Strikethrough']} />
  250. <CodeToggle />
  251. <Separator />
  252. <ListsToggle />
  253. <Separator />
  254. <BlockTypeSelect />
  255. <Separator />
  256. <CreateLink />
  257. </DiffSourceToggleWrapper>
  258. ),
  259. }),
  260. )
  261. }
  262. return list
  263. }, [toolbarVisible, isDark])
  264. // Outer shell clips rounded corners + inset outline; inner pane scrolls.
  265. // (overflow:auto on the same node as rounded-xl + shadow-input-outline
  266. // makes sticky toolbar corners look crooked.)
  267. const shellClassName =
  268. bg === 'transparent'
  269. ? 'min-w-0 w-full overflow-hidden'
  270. : cn(
  271. 'min-w-0 w-full overflow-hidden',
  272. inputSettings.base,
  273. inputSettings.rounded.default,
  274. )
  275. const scrollStyle = {
  276. minHeight: initialHeight,
  277. maxHeight,
  278. overflow: 'auto',
  279. WebkitOverflowScrolling: 'touch',
  280. overscrollBehavior: 'contain',
  281. }
  282. if (!mounted) {
  283. return (
  284. <div className={shellClassName}>
  285. <div
  286. className="flex w-full items-center justify-center"
  287. style={scrollStyle}
  288. >
  289. <Loading />
  290. </div>
  291. </div>
  292. )
  293. }
  294. return (
  295. <div className={shellClassName}>
  296. <div className="w-full" style={scrollStyle}>
  297. <MDXEditor
  298. ref={editorRef}
  299. markdown={initialMarkdown.current}
  300. placeholder={placeholder}
  301. readOnly={!!disabled}
  302. autoFocus={!disabled && !!autofocus}
  303. // Wiki markdown is not MDX — HTML/`<br/>`/`</tag>`/`<https://…>` must not
  304. // go through mdast-util-mdx-jsx or rich-text parse fails (see emails-notifications).
  305. suppressHtmlProcessing
  306. onChange={(markdown, initialMarkdownNormalize) => {
  307. if (disabled) return
  308. const next = markdown ?? ''
  309. lastWrittenRef.current = next
  310. if (initialMarkdownNormalize) return
  311. field.onChange(next)
  312. }}
  313. onBlur={field.onBlur}
  314. plugins={plugins}
  315. contentEditableClassName="prose prose-sm dark:prose-invert max-w-none min-h-[7rem] px-3 py-2 outline-none"
  316. className={cn(
  317. 'mdxeditor mdxeditor-wiki w-full text-card-foreground',
  318. isDark && 'dark-theme',
  319. )}
  320. />
  321. </div>
  322. </div>
  323. )
  324. }