editor-inner-enriched.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. 'use client'
  2. import { useFormContext } from 'react-hook-form'
  3. import { useState, useRef, useEffect, useMemo, useCallback, Component } from 'react'
  4. import { Platform, TextInput } from 'react-native'
  5. import { EnrichedTextInput } from 'react-native-enriched-html'
  6. import { View } from 'app/design/view'
  7. import { Text } from 'app/design/typography'
  8. import { useTranslation } from 'react-i18next'
  9. import { EditorToolbar, useEnrichedToolbar } from 'app/lib/editor/editor-toolbar'
  10. import { useThemeValue } from 'app/design/theme'
  11. import { appSetting, cn } from 'app/lib/util'
  12. import { commitEditorHtml, normalizeEditorHtml } from 'app/lib/editor/editor-form'
  13. import { useEditorFormField } from 'app/lib/form/use-form-field'
  14. import emitter, { EVENTS } from 'app/context/emitter'
  15. import { mentionsToUnaLinks, unaLinksToMentions, linkifyHtml } from './editor-mention-html'
  16. import { alignClassesToStyles } from 'app/lib/html-helpers'
  17. import { useEditorMentions } from 'app/lib/editor/use-editor-mentions'
  18. import { MentionSuggestionsDropdown } from 'app/lib/editor/mention-suggestions-dropdown'
  19. import {
  20. MENTION_INDICATORS,
  21. insertMentionInTiptap,
  22. mentionAttributesForUser,
  23. } from 'app/lib/editor/editor-mention-shared'
  24. import { useTiptapEditor } from 'app/lib/editor/use-tiptap-editor'
  25. import { useEnrichedMentionDropdown } from 'app/lib/editor/use-enriched-mention-dropdown'
  26. import { useEnrichedKeyboard } from 'app/lib/editor/use-enriched-keyboard'
  27. import { useIsDesktop } from 'app/context/measure'
  28. import { fitInlineImageSize, getEditorPasteImagesMode, revokePastedBlobUri } from 'app/lib/editor/editor-paste-images'
  29. import { pickEditorImages, pickEditorVideo } from 'app/lib/editor/pick-editor-images'
  30. import { uploadInlineVideo, toVideoPosterSrc } from 'app/lib/editor/inline-video'
  31. import { useEditorEmbedViews } from 'app/lib/editor/editor-embed-views'
  32. import { uploadInlineImage, swapEditorImageSrc } from 'app/lib/editor/upload-inline-image'
  33. import { trackFormUploadStart, trackFormUploadEnd } from 'app/lib/form/form-helpers'
  34. import { useFormInstanceId } from 'app/context/form-instance'
  35. import { useEnrichedHtmlStyle } from 'app/lib/editor/enriched-html-style'
  36. import { useMarkdownPaste } from 'app/lib/editor/markdown-paste'
  37. const isWeb = Platform.OS === 'web'
  38. const inputSettings = appSetting('theme', 'inputs')
  39. const EMPTY_SELECTION = { start: 0, end: 0, text: '' }
  40. /** Markdown typed at the start of a line (every platform); the library default has only the lists. */
  41. const MARKDOWN_SHORTCUTS = [
  42. { trigger: '- ', style: 'unordered_list' },
  43. { trigger: '1. ', style: 'ordered_list' },
  44. { trigger: '# ', style: 'h1' },
  45. { trigger: '## ', style: 'h2' },
  46. { trigger: '### ', style: 'h3' },
  47. { trigger: '> ', style: 'blockquote' },
  48. ]
  49. /** One block per line in the source view; a newline between blocks is not content. */
  50. const formatSourceHtml = (html) => html
  51. .replace(/(<\/(?:p|h[1-6]|ul|ol|li|blockquote|div)>)(?!\n)(?=.)/gi, '$1\n')
  52. /** Call an imperative method on the library handle, tolerating a not-ready editor. */
  53. function callEditor(editorRef, method, ...args) {
  54. try {
  55. editorRef.current?.[method]?.(...args)
  56. } catch {
  57. // Web TipTap handle exists before editor.commands is ready.
  58. }
  59. }
  60. class EnrichedEditorBoundary extends Component {
  61. constructor(props) {
  62. super(props)
  63. this.state = { gen: 0 }
  64. }
  65. static getDerivedStateFromError() {
  66. return {}
  67. }
  68. componentDidCatch() {
  69. this.setState((state) => ({ gen: Math.min(state.gen + 1, 3) }))
  70. }
  71. render() {
  72. if (this.state.gen >= 3) return null
  73. return <View key={this.state.gen}>{this.props.children}</View>
  74. }
  75. }
  76. export default function RftTextEnriched(props) {
  77. const {
  78. value = '',
  79. initialHeight = 120,
  80. maxHeight = 300,
  81. onFocus,
  82. html,
  83. bg,
  84. enableSubmitOnEnter = false,
  85. placeholder,
  86. form_name,
  87. container_class,
  88. kb_stay_open,
  89. onEnterSubmit,
  90. disabled,
  91. autofocus,
  92. } = props
  93. const { name, field, readOnly } = useEditorFormField(props)
  94. const { resetField, formState } = useFormContext()
  95. const isDisabled = !!(disabled || readOnly)
  96. const editorRef = useRef(null)
  97. const containerRef = useRef(null)
  98. const dropdownRef = useRef(null)
  99. const isDesktop = useIsDesktop()
  100. const formInstanceId = useFormInstanceId()
  101. const { t } = useTranslation()
  102. const isToolBar = html == 2 || html == 1
  103. // UNA html=3: rich editor, no toolbar. Images paste into attachments, not <img>.
  104. const isLimitedHtml = html == 3
  105. const isCommentsEditor = container_class === 'comments'
  106. const editorTextColor = useThemeValue('rgba(30,40,55,1)', 'rgba(225,230,240,1)')
  107. // Web only; null on native and until the library has mounted TipTap.
  108. const tipTap = useTiptapEditor(containerRef)
  109. // Web: embed images in the body show as embed cards (portals into their node views).
  110. const embedViews = useEditorEmbedViews(tipTap)
  111. // Web: pasted Markdown text becomes formatted content (toolbar editors only).
  112. const isRich = isToolBar && !isLimitedHtml
  113. useMarkdownPaste(tipTap, isRich)
  114. // Toolbar inputs — only tracked when there is a toolbar to feed.
  115. const [styleState, setStyleState] = useState(null)
  116. const [selection, setSelection] = useState(EMPTY_SELECTION)
  117. const onChangeState = useCallback((e) => setStyleState(e.nativeEvent), [])
  118. const onChangeSelection = useCallback((e) => setSelection(e.nativeEvent || EMPTY_SELECTION), [])
  119. // Shared mention pipeline (same fetch/recents/nav as tentap).
  120. const {
  121. indicator: mentionIndicator,
  122. suggestions,
  123. setTrigger,
  124. clearTrigger,
  125. recordMention,
  126. handlersRef,
  127. } = useEditorMentions({ fieldName: name, mentions: props.mentions })
  128. // ---- initial / external value ----
  129. // EnrichedTextInput's web build puts `defaultValue` in useEditor deps, so a
  130. // changing defaultValue destroys/recreates TipTap. After destroy, schema is
  131. // null and getHTML() throws ("Cannot read properties of null (reading 'cached')").
  132. // Keep defaultValue fixed for the component lifetime; push later updates via setValue.
  133. const [initialDefaultValue] = useState(() => alignClassesToStyles(unaLinksToMentions(value || '')))
  134. const lastSetValue = useRef(value)
  135. // HTML source view text, null while the editor is shown (see toggleSource). The ref
  136. // mirrors 'open' for effects: external content (a reset after submit, set_content)
  137. // replaces an open source view, and a closed one gets no no-op update.
  138. const [sourceHtml, setSourceHtml] = useState(null)
  139. const sourceOpenRef = useRef(false)
  140. useEffect(() => {
  141. if (editorRef.current && value !== lastSetValue.current) {
  142. lastSetValue.current = value
  143. callEditor(editorRef, 'setValue', alignClassesToStyles(unaLinksToMentions(value || '')))
  144. if (sourceOpenRef.current) setSourceHtml(formatSourceHtml(value || ''))
  145. }
  146. }, [value])
  147. useEffect(() => {
  148. if (!autofocus || !isWeb) return undefined
  149. const timer = setTimeout(() => callEditor(editorRef, 'focus'), 80)
  150. return () => clearTimeout(timer)
  151. }, [autofocus])
  152. // ---- emitter (focus/blur/set_content/insert_inline_image), same as tentap ----
  153. useEffect(() => {
  154. const sub = emitter.addListener(EVENTS.editor, (data) => {
  155. if (!editorRef.current) return
  156. switch (data.action) {
  157. case 'blur':
  158. if (data.timeout) setTimeout(() => callEditor(editorRef, 'blur'), data.timeout)
  159. else callEditor(editorRef, 'blur')
  160. break
  161. case 'focus':
  162. if (isWeb) {
  163. callEditor(editorRef, 'focus')
  164. } else {
  165. // iOS: after the keyboard is dismissed the native input can keep a
  166. // stale first-responder state, so a plain focus() no-ops. Blur first
  167. // to clear it, then focus to reliably re-summon the keyboard.
  168. callEditor(editorRef, 'blur')
  169. setTimeout(() => callEditor(editorRef, 'focus'), 50)
  170. }
  171. break
  172. case 'set_content':
  173. lastSetValue.current = data.value
  174. callEditor(editorRef, 'setValue', alignClassesToStyles(unaLinksToMentions(data.value || '')))
  175. if (sourceOpenRef.current) setSourceHtml(formatSourceHtml(data.value || ''))
  176. break
  177. case 'insert_inline_image': {
  178. if (data.form_name && form_name && data.form_name !== form_name) return
  179. if (!data.src || isLimitedHtml) return
  180. const size = fitInlineImageSize(data.width, data.height)
  181. callEditor(editorRef, 'setImage', data.src, size.width, size.height)
  182. break
  183. }
  184. default:
  185. break
  186. }
  187. })
  188. return () => sub.remove()
  189. }, [form_name, isLimitedHtml])
  190. useEffect(() => {
  191. if (formState.isSubmitted && kb_stay_open != true) {
  192. callEditor(editorRef, 'blur')
  193. }
  194. }, [formState.isSubmitted, kb_stay_open])
  195. // ---- mentions ----
  196. const insertMention = useCallback((user) => {
  197. const indicator = mentionIndicator || '@'
  198. // Web: direct TipTap insert (same @-token parse as tentap). Library
  199. // setMention no-ops when blur ended the MentionPlugin trigger.
  200. const inserted = tipTap ? insertMentionInTiptap(tipTap, user, indicator) : false
  201. if (!inserted) {
  202. editorRef.current?.setMention?.(
  203. indicator,
  204. user.label.trim(),
  205. mentionAttributesForUser(user)
  206. )
  207. }
  208. recordMention(user)
  209. clearTrigger()
  210. }, [mentionIndicator, tipTap, recordMention, clearTrigger])
  211. const dropdownPos = useEnrichedMentionDropdown({
  212. tipTap,
  213. containerRef,
  214. dropdownRef,
  215. hasSuggestions: suggestions.length > 0,
  216. setTrigger,
  217. })
  218. // Native library callbacks (web derives the trigger from TipTap above).
  219. const onStartMention = useCallback((indicator) => {
  220. if (!isWeb) setTrigger('', indicator)
  221. }, [setTrigger])
  222. const onChangeMention = useCallback((e) => {
  223. if (!isWeb) setTrigger(e.text, e.indicator)
  224. }, [setTrigger])
  225. const onEndMention = useCallback(() => {
  226. if (!isWeb) clearTrigger()
  227. }, [clearTrigger])
  228. // ---- HTML -> react-hook-form ----
  229. const isInitialHtmlEmission = useRef(true)
  230. const onChangeHtml = useCallback((e) => {
  231. // Library wraps TipTap HTML in <html>...</html>; strip so RHF value matches
  232. // UNA content and doesn't look like an external edit to the value effect.
  233. let raw = e?.nativeEvent?.value ?? ''
  234. raw = raw.replace(/^<html>/i, '').replace(/<\/html>$/i, '')
  235. // Convert mentions to UNA links, then auto-link any plain URLs/emails the
  236. // editor didn't catch itself (web has the library's autolink disabled), so
  237. // saved content has real <a> links on every platform.
  238. const html = linkifyHtml(mentionsToUnaLinks(raw))
  239. if (onFocus && html) onFocus()
  240. const next = normalizeEditorHtml(html, { isLimitedHtml })
  241. commitEditorHtml({
  242. next,
  243. field,
  244. name,
  245. resetField,
  246. isInitialRef: isInitialHtmlEmission,
  247. })
  248. lastSetValue.current = next
  249. }, [field, isLimitedHtml, name, onFocus, resetField])
  250. // ---- images (paste + toolbar picker) ----
  251. // Toolbar editors put images in the body by default; html=3 cannot keep <img>.
  252. // Inline: upload to the editor storage (like UNA's Quill) and insert the link —
  253. // not via the files field, whose ghosts become attachments with private URLs.
  254. const pasteImagesMode = form_name
  255. ? getEditorPasteImagesMode(form_name, isToolBar ? 'inline' : undefined)
  256. : 'off'
  257. const isInline = !isLimitedHtml && pasteImagesMode === 'inline'
  258. // Why the last inline image/video didn't make it into the body (UNA's text when it gave one).
  259. const [mediaError, setMediaError] = useState(null)
  260. const showUploadError = useCallback((error) => setMediaError(error || t('Upload failed')), [t])
  261. const sendImagesToFiles = useCallback((images) => {
  262. if (!images?.length || pasteImagesMode === 'off') return
  263. if (isInline) {
  264. setMediaError(null)
  265. images.forEach(async (img, index) => {
  266. // Placeholder: the local blob:/file:// image right away, swapped for the
  267. // uploaded URL (or dropped on failure). Submit waits on the pending upload.
  268. const uploadId = `inline-${Date.now()}-${index}`
  269. const size = fitInlineImageSize(img.width, img.height)
  270. trackFormUploadStart(form_name, formInstanceId, uploadId)
  271. callEditor(editorRef, 'setImage', img.uri, size.width, size.height)
  272. try {
  273. const { link, error } = await uploadInlineImage(img)
  274. await swapEditorImageSrc(editorRef, tipTap, img.uri, link)
  275. if (!link) showUploadError(error)
  276. } finally {
  277. trackFormUploadEnd(form_name, formInstanceId, uploadId)
  278. revokePastedBlobUri(img.uri)
  279. }
  280. })
  281. return
  282. }
  283. emitter.emit(EVENTS.form(form_name), {
  284. action: 'pasted_images',
  285. images,
  286. inlinePaste: false,
  287. })
  288. }, [form_name, formInstanceId, isInline, pasteImagesMode, tipTap, showUploadError])
  289. const onInsertImage = useCallback(async () => {
  290. const images = await pickEditorImages()
  291. sendImagesToFiles(images)
  292. }, [sendImagesToFiles])
  293. // Video: the body keeps its poster image (the editors have no <video>/<iframe>; an
  294. // image is atomic on every platform); the post view renders it as a player — see
  295. // splitInlineMedia. Until transcoded the poster link shows UNA's video-na.png.
  296. const [videoProgress, setVideoProgress] = useState(null)
  297. const onInsertVideo = useCallback(async () => {
  298. const video = await pickEditorVideo()
  299. if (!video) return
  300. const uploadId = `inline-video-${Date.now()}`
  301. trackFormUploadStart(form_name, formInstanceId, uploadId)
  302. setMediaError(null)
  303. setVideoProgress(0)
  304. try {
  305. const { link, error } = await uploadInlineVideo(video, setVideoProgress)
  306. if (link) {
  307. const size = video.width && video.height
  308. ? fitInlineImageSize(video.width, video.height)
  309. : { width: 720, height: 405 }
  310. callEditor(editorRef, 'setImage', toVideoPosterSrc(link), size.width, size.height)
  311. } else {
  312. showUploadError(error)
  313. }
  314. } finally {
  315. setVideoProgress(null)
  316. trackFormUploadEnd(form_name, formInstanceId, uploadId)
  317. }
  318. }, [form_name, formInstanceId, showUploadError])
  319. // Library onPasteImages: blob: on web, file:// on native.
  320. const onPasteImages = useCallback((e) => {
  321. const imgs = e?.nativeEvent?.images || []
  322. if (!imgs.length) return
  323. const images = imgs.map((img, index) => {
  324. const mimeType = img.type || 'image/png'
  325. const ext = mimeType.split('/')[1] || 'png'
  326. return {
  327. uri: img.uri,
  328. fileName: `pasted-${Date.now()}-${index}.${ext}`,
  329. mimeType,
  330. width: img.width,
  331. height: img.height,
  332. }
  333. })
  334. sendImagesToFiles(images)
  335. }, [sendImagesToFiles])
  336. // ---- Enter routing (desktop only; mobile Enter inserts newline) ----
  337. const submitOnEnter = isDesktop && (
  338. isCommentsEditor
  339. ? (enableSubmitOnEnter || appSetting('comments', 'submit_comment_on_enter'))
  340. : enableSubmitOnEnter
  341. )
  342. // Comment composers stay in newline mode and route Enter in useEnrichedKeyboard.
  343. const submitBehavior = submitOnEnter && !isCommentsEditor ? 'submit' : 'newline'
  344. const onSubmitEditing = useCallback(() => {
  345. if (isCommentsEditor && submitOnEnter) return
  346. if (onEnterSubmit) onEnterSubmit()
  347. }, [isCommentsEditor, submitOnEnter, onEnterSubmit])
  348. const onKeyPress = useEnrichedKeyboard({
  349. tipTap,
  350. isCommentsEditor,
  351. submitOnEnter,
  352. handlersRef,
  353. insertMention,
  354. onEnterSubmit,
  355. })
  356. // Same styles as the published post: `htmlStyle` on native, the `u-vanilla-html`
  357. // class on web (design/styles/global.web.css bridges the library's own CSS).
  358. const htmlStyle = useEnrichedHtmlStyle({ small: isCommentsEditor })
  359. const contentClass = isCommentsEditor ? 'u-vanilla-html-small' : 'u-vanilla-html'
  360. const editorStyle = useMemo(() => ({
  361. minHeight: initialHeight,
  362. maxHeight,
  363. color: editorTextColor,
  364. fontSize: 16,
  365. backgroundColor: 'transparent',
  366. }), [initialHeight, maxHeight, editorTextColor])
  367. const handleFocus = useCallback(() => {
  368. if (onFocus) onFocus()
  369. }, [onFocus])
  370. // Web: clicks on padding / empty chrome around ProseMirror (toolbar field
  371. // padding, areas outside the stretched contenteditable) should still focus.
  372. const onContainerMouseDown = useCallback((e) => {
  373. if (isDisabled) return
  374. const target = e?.target
  375. if (!target?.closest) return
  376. if (target.closest('.ProseMirror, textarea')) return
  377. if (target.closest('a, button, [role="button"]')) return
  378. if (dropdownRef.current?.contains?.(target)) return
  379. e.preventDefault?.()
  380. callEditor(editorRef, 'focus')
  381. }, [isDisabled])
  382. // ---- HTML source view (toolbar button) ----
  383. // The editor stays mounted (hidden): recreating TipTap loses its state. Source edits
  384. // go straight to the form value, so a submit from this view saves them; leaving it
  385. // loads the HTML back into the editor, which drops what its schema can't hold.
  386. const isSource = sourceHtml !== null
  387. const onChangeSource = useCallback((text) => {
  388. setSourceHtml(text)
  389. const next = normalizeEditorHtml(text, { isLimitedHtml })
  390. field.onChange(next)
  391. lastSetValue.current = next
  392. }, [field, isLimitedHtml])
  393. const toggleSource = useCallback(() => {
  394. if (sourceHtml === null) {
  395. sourceOpenRef.current = true
  396. setSourceHtml(formatSourceHtml(field.value || ''))
  397. return
  398. }
  399. sourceOpenRef.current = false
  400. callEditor(editorRef, 'setValue', alignClassesToStyles(unaLinksToMentions(sourceHtml)))
  401. setSourceHtml(null)
  402. setTimeout(() => callEditor(editorRef, 'focus'), 0)
  403. }, [sourceHtml, field.value])
  404. const source = useMemo(
  405. () => (isToolBar && !isLimitedHtml ? { active: isSource, onToggle: toggleSource } : null),
  406. [isToolBar, isLimitedHtml, isSource, toggleSource]
  407. )
  408. const { items: toolbarItems, linkBar } = useEnrichedToolbar({
  409. editorRef,
  410. containerRef,
  411. tipTap,
  412. styleState,
  413. selection,
  414. enabled: isToolBar,
  415. onInsertImage: pasteImagesMode === 'off' ? undefined : onInsertImage,
  416. onInsertVideo: isInline ? onInsertVideo : undefined,
  417. embeds: !isLimitedHtml,
  418. source,
  419. })
  420. const surfaceClass = isToolBar
  421. ? ' px-3 py-2 bg-input/60 shadow-input-outline dark:shadow-input-outline-deep rounded-xl flex-auto overflow-hidden text-card-foreground '
  422. : (bg == 'transparent' ? '' : cn(inputSettings.base, inputSettings.size.regular))
  423. return (
  424. <View
  425. ref={containerRef}
  426. onMouseDown={isWeb ? onContainerMouseDown : undefined}
  427. className="relative flex-auto web:cursor-text overflow-visible"
  428. >
  429. <MentionSuggestionsDropdown
  430. suggestions={suggestions}
  431. onSelect={insertMention}
  432. dropdownRef={dropdownRef}
  433. className={dropdownPos ? '' : 'bottom-0'}
  434. style={
  435. dropdownPos
  436. ? {
  437. position: 'absolute',
  438. left: 0,
  439. alignSelf: 'flex-start',
  440. maxHeight: dropdownPos.maxHeight,
  441. ...(dropdownPos.top != null
  442. ? { top: dropdownPos.top }
  443. : { bottom: dropdownPos.bottom }),
  444. }
  445. : undefined
  446. }
  447. />
  448. <View className={surfaceClass}>
  449. {isSource ? (
  450. <TextInput
  451. value={sourceHtml}
  452. onChangeText={onChangeSource}
  453. multiline
  454. autoFocus
  455. autoCapitalize="none"
  456. autoCorrect={false}
  457. spellCheck={false}
  458. editable={!isDisabled}
  459. textAlignVertical="top"
  460. accessibilityLabel={t('HTML source')}
  461. className="font-mono text-sm text-foreground bg-transparent"
  462. style={{ height: Math.max(initialHeight, maxHeight), ...(isWeb ? { outlineStyle: 'none', resize: 'vertical' } : null) }}
  463. />
  464. ) : null}
  465. <View className={cn(isWeb && contentClass, isSource && 'hidden')}>
  466. <EnrichedEditorBoundary>
  467. <EnrichedTextInput
  468. ref={editorRef}
  469. defaultValue={initialDefaultValue}
  470. placeholder={placeholder}
  471. placeholderTextColor="rgba(120,130,145,1)"
  472. editable={!isDisabled}
  473. autoFocus={isWeb ? false : !!autofocus}
  474. autoCapitalize="none"
  475. mentionIndicators={MENTION_INDICATORS}
  476. textShortcuts={isRich ? MARKDOWN_SHORTCUTS : undefined}
  477. htmlStyle={htmlStyle}
  478. submitBehavior={submitBehavior}
  479. style={editorStyle}
  480. onChangeHtml={onChangeHtml}
  481. onChangeState={isToolBar ? onChangeState : undefined}
  482. onChangeSelection={isToolBar ? onChangeSelection : undefined}
  483. onStartMention={onStartMention}
  484. onChangeMention={onChangeMention}
  485. onEndMention={onEndMention}
  486. onPasteImages={onPasteImages}
  487. onSubmitEditing={onSubmitEditing}
  488. onKeyPress={onKeyPress}
  489. onFocus={handleFocus}
  490. />
  491. </EnrichedEditorBoundary>
  492. </View>
  493. {embedViews}
  494. {isToolBar ? (
  495. <EditorToolbar items={toolbarItems} linkBar={linkBar} />
  496. ) : null}
  497. {videoProgress != null ? (
  498. <Text className="text-xs text-muted-foreground mt-1">
  499. {t('Uploading video')} {Math.round(videoProgress * 100)}%
  500. </Text>
  501. ) : null}
  502. {mediaError && videoProgress == null ? (
  503. <Text className="text-xs text-destructive mt-1" accessibilityRole="alert">
  504. {mediaError}
  505. </Text>
  506. ) : null}
  507. </View>
  508. </View>
  509. )
  510. }