editor-markdown.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  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 { EnrichedMarkdownTextInput } from 'react-native-enriched-markdown'
  7. import { NeoButton } from 'app/design/controls'
  8. import { View } from 'app/design/view'
  9. import { useTheme, useThemeValue } from 'app/design/theme'
  10. import { useCSSVariable } from 'uniwind'
  11. import { appSetting, cn } from 'app/lib/util'
  12. import emitter, { EVENTS } from 'app/context/emitter'
  13. import { useTranslation } from 'react-i18next'
  14. const inputSettings = appSetting('theme', 'inputs')
  15. /**
  16. * Wiki Markdown form field (native) — EnrichedMarkdownTextInput.
  17. * Web resolves `editor-markdown.web.js` (@mdxeditor/editor).
  18. */
  19. export default function FormFieldMarkdownEditor(props) {
  20. const { t } = useTranslation()
  21. const formContext = useFormContext()
  22. const placeholder = props.use_caption_as_placeholder
  23. ? props.caption
  24. : props.placeholder
  25. const isCommentsForm = props.container_class === 'comments'
  26. const initialHeight = props.height || (isCommentsForm ? 48 : 120)
  27. const rules = useMemo(() => getValidationRules(props), [
  28. props.checker,
  29. props.required,
  30. props.name,
  31. ])
  32. const disabled =
  33. props?.attrs?.readonly == 'readonly' ||
  34. props?.attrs?.disabled == 'disabled' ||
  35. !!props.disabled
  36. return (
  37. <Field {...props} error2={formContext.formState.errors[props.name]}>
  38. <MarkdownTextInput
  39. name={props.name}
  40. value={props.value}
  41. bg={props.bg}
  42. disabled={disabled}
  43. autofocus={props.autofocus}
  44. onFocus={props.onFocus}
  45. placeholder={placeholder}
  46. initialHeight={initialHeight}
  47. maxHeight={props.maxHeight || 400}
  48. showToolbar={props.showToolbar !== false}
  49. rules={rules}
  50. />
  51. </Field>
  52. )
  53. }
  54. export function MarkdownTextInput({
  55. name,
  56. value: valueProp,
  57. initialHeight = 120,
  58. maxHeight = 300,
  59. onFocus,
  60. bg,
  61. placeholder,
  62. autofocus,
  63. disabled,
  64. showToolbar = true,
  65. rules = {},
  66. }) {
  67. const { t } = useTranslation()
  68. const externalValue = valueProp ?? ''
  69. const { field } = useFieldController({
  70. name,
  71. rules,
  72. defaultValue: externalValue,
  73. })
  74. const { setValue } = useFormContext()
  75. const editorRef = useRef(null)
  76. const { colors } = useTheme()
  77. const [styleState, setStyleState] = useState(null)
  78. const [foregroundToken] = useCSSVariable(['--color-card-foreground'])
  79. const initialDefaultValue = useRef(externalValue)
  80. const lastWrittenRef = useRef(externalValue)
  81. useEffect(() => {
  82. if (externalValue === lastWrittenRef.current) return
  83. lastWrittenRef.current = externalValue
  84. setValue(name, externalValue)
  85. editorRef.current?.setValue?.(externalValue)
  86. }, [externalValue, name, setValue])
  87. useEffect(() => {
  88. const sub = emitter.addListener(EVENTS.editor, (data) => {
  89. if (!editorRef.current) return
  90. if (data.action === 'focus') {
  91. editorRef.current.focus()
  92. }
  93. if (data.action === 'set_content') {
  94. const next = data.value || ''
  95. lastWrittenRef.current = next
  96. editorRef.current.setValue(next)
  97. field.onChange(next)
  98. }
  99. })
  100. return () => sub.remove()
  101. }, [field])
  102. const onMarkdownChange = (markdown) => {
  103. const next = markdown ?? ''
  104. lastWrittenRef.current = next
  105. field.onChange(next)
  106. }
  107. const surfaceClassName =
  108. bg === 'transparent'
  109. ? 'flex-auto overflow-hidden'
  110. : cn(
  111. 'flex-auto overflow-hidden',
  112. inputSettings.base,
  113. inputSettings.size.regular,
  114. )
  115. const editorTextColor = foregroundToken || colors.default
  116. const linkColor = useThemeValue('rgba(37, 99, 235, 1)', 'rgba(59, 130, 246, 1)')
  117. const markdownStyle = useMemo(
  118. () => ({
  119. strong: { color: editorTextColor },
  120. em: { color: editorTextColor },
  121. link: { color: linkColor, underline: true },
  122. }),
  123. [editorTextColor, linkColor],
  124. )
  125. return (
  126. <View className={surfaceClassName}>
  127. <EnrichedMarkdownTextInput
  128. ref={editorRef}
  129. defaultValue={initialDefaultValue.current}
  130. placeholder={placeholder}
  131. placeholderTextColor="rgba(120,130,145,1)"
  132. editable={!disabled}
  133. autoFocus={!!autofocus}
  134. multiline
  135. scrollEnabled
  136. markdownStyle={markdownStyle}
  137. style={{
  138. minHeight: initialHeight,
  139. maxHeight,
  140. color: editorTextColor,
  141. fontSize: 16,
  142. backgroundColor: 'transparent',
  143. }}
  144. onChangeMarkdown={onMarkdownChange}
  145. onChangeState={setStyleState}
  146. onFocus={() => {
  147. if (onFocus) onFocus()
  148. }}
  149. onBlur={field.onBlur}
  150. />
  151. {showToolbar && !disabled && styleState ? (
  152. <View className="mt-2 flex-row flex-wrap gap-2">
  153. <ToolbarButton
  154. label="B"
  155. accessibilityLabel={t('Bold')}
  156. active={styleState.bold?.isActive}
  157. onPress={() => editorRef.current?.toggleBold()}
  158. />
  159. <ToolbarButton
  160. label="I"
  161. accessibilityLabel={t('Italic')}
  162. active={styleState.italic?.isActive}
  163. onPress={() => editorRef.current?.toggleItalic()}
  164. />
  165. <ToolbarButton
  166. label="U"
  167. accessibilityLabel={t('Underline')}
  168. active={styleState.underline?.isActive}
  169. onPress={() => editorRef.current?.toggleUnderline()}
  170. />
  171. <ToolbarButton
  172. label="S"
  173. accessibilityLabel={t('Strikethrough')}
  174. active={styleState.strikethrough?.isActive}
  175. onPress={() => editorRef.current?.toggleStrikethrough()}
  176. />
  177. <ToolbarButton
  178. label={t('Link')}
  179. active={styleState.link?.isActive}
  180. onPress={() => {
  181. if (styleState.link?.isActive) {
  182. editorRef.current?.removeLink()
  183. return
  184. }
  185. editorRef.current?.insertLink('link', 'https://')
  186. }}
  187. />
  188. </View>
  189. ) : null}
  190. </View>
  191. )
  192. }
  193. function ToolbarButton({ label, accessibilityLabel, active, onPress }) {
  194. return (
  195. <NeoButton
  196. style="borderless"
  197. controlSize="mini"
  198. selected={!!active}
  199. label={label}
  200. accessibilityLabel={accessibilityLabel}
  201. haptics={false}
  202. onPress={onPress}
  203. />
  204. )
  205. }