| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551 |
- 'use client'
- import { useFormContext } from 'react-hook-form'
- import { useState, useRef, useEffect, useMemo, useCallback, Component } from 'react'
- import { Platform, TextInput } from 'react-native'
- import { EnrichedTextInput } from 'react-native-enriched-html'
- import { View } from 'app/design/view'
- import { Text } from 'app/design/typography'
- import { useTranslation } from 'react-i18next'
- import { EditorToolbar, useEnrichedToolbar } from 'app/lib/editor/editor-toolbar'
- import { useThemeValue } from 'app/design/theme'
- import { appSetting, cn } from 'app/lib/util'
- import { commitEditorHtml, normalizeEditorHtml } from 'app/lib/editor/editor-form'
- import { useEditorFormField } from 'app/lib/form/use-form-field'
- import emitter, { EVENTS } from 'app/context/emitter'
- import { mentionsToUnaLinks, unaLinksToMentions, linkifyHtml } from './editor-mention-html'
- import { alignClassesToStyles } from 'app/lib/html-helpers'
- import { useEditorMentions } from 'app/lib/editor/use-editor-mentions'
- import { MentionSuggestionsDropdown } from 'app/lib/editor/mention-suggestions-dropdown'
- import {
- MENTION_INDICATORS,
- insertMentionInTiptap,
- mentionAttributesForUser,
- } from 'app/lib/editor/editor-mention-shared'
- import { useTiptapEditor } from 'app/lib/editor/use-tiptap-editor'
- import { useEnrichedMentionDropdown } from 'app/lib/editor/use-enriched-mention-dropdown'
- import { useEnrichedKeyboard } from 'app/lib/editor/use-enriched-keyboard'
- import { useIsDesktop } from 'app/context/measure'
- import { fitInlineImageSize, getEditorPasteImagesMode, revokePastedBlobUri } from 'app/lib/editor/editor-paste-images'
- import { pickEditorImages, pickEditorVideo } from 'app/lib/editor/pick-editor-images'
- import { uploadInlineVideo, toVideoPosterSrc } from 'app/lib/editor/inline-video'
- import { useEditorEmbedViews } from 'app/lib/editor/editor-embed-views'
- import { uploadInlineImage, swapEditorImageSrc } from 'app/lib/editor/upload-inline-image'
- import { trackFormUploadStart, trackFormUploadEnd } from 'app/lib/form/form-helpers'
- import { useFormInstanceId } from 'app/context/form-instance'
- import { useEnrichedHtmlStyle } from 'app/lib/editor/enriched-html-style'
- import { useMarkdownPaste } from 'app/lib/editor/markdown-paste'
- const isWeb = Platform.OS === 'web'
- const inputSettings = appSetting('theme', 'inputs')
- const EMPTY_SELECTION = { start: 0, end: 0, text: '' }
- /** Markdown typed at the start of a line (every platform); the library default has only the lists. */
- const MARKDOWN_SHORTCUTS = [
- { trigger: '- ', style: 'unordered_list' },
- { trigger: '1. ', style: 'ordered_list' },
- { trigger: '# ', style: 'h1' },
- { trigger: '## ', style: 'h2' },
- { trigger: '### ', style: 'h3' },
- { trigger: '> ', style: 'blockquote' },
- ]
- /** One block per line in the source view; a newline between blocks is not content. */
- const formatSourceHtml = (html) => html
- .replace(/(<\/(?:p|h[1-6]|ul|ol|li|blockquote|div)>)(?!\n)(?=.)/gi, '$1\n')
- /** Call an imperative method on the library handle, tolerating a not-ready editor. */
- function callEditor(editorRef, method, ...args) {
- try {
- editorRef.current?.[method]?.(...args)
- } catch {
- // Web TipTap handle exists before editor.commands is ready.
- }
- }
- class EnrichedEditorBoundary extends Component {
- constructor(props) {
- super(props)
- this.state = { gen: 0 }
- }
- static getDerivedStateFromError() {
- return {}
- }
- componentDidCatch() {
- this.setState((state) => ({ gen: Math.min(state.gen + 1, 3) }))
- }
- render() {
- if (this.state.gen >= 3) return null
- return <View key={this.state.gen}>{this.props.children}</View>
- }
- }
- export default function RftTextEnriched(props) {
- const {
- value = '',
- initialHeight = 120,
- maxHeight = 300,
- onFocus,
- html,
- bg,
- enableSubmitOnEnter = false,
- placeholder,
- form_name,
- container_class,
- kb_stay_open,
- onEnterSubmit,
- disabled,
- autofocus,
- } = props
- const { name, field, readOnly } = useEditorFormField(props)
- const { resetField, formState } = useFormContext()
- const isDisabled = !!(disabled || readOnly)
- const editorRef = useRef(null)
- const containerRef = useRef(null)
- const dropdownRef = useRef(null)
- const isDesktop = useIsDesktop()
- const formInstanceId = useFormInstanceId()
- const { t } = useTranslation()
- const isToolBar = html == 2 || html == 1
- // UNA html=3: rich editor, no toolbar. Images paste into attachments, not <img>.
- const isLimitedHtml = html == 3
- const isCommentsEditor = container_class === 'comments'
- const editorTextColor = useThemeValue('rgba(30,40,55,1)', 'rgba(225,230,240,1)')
- // Web only; null on native and until the library has mounted TipTap.
- const tipTap = useTiptapEditor(containerRef)
- // Web: embed images in the body show as embed cards (portals into their node views).
- const embedViews = useEditorEmbedViews(tipTap)
- // Web: pasted Markdown text becomes formatted content (toolbar editors only).
- const isRich = isToolBar && !isLimitedHtml
- useMarkdownPaste(tipTap, isRich)
- // Toolbar inputs — only tracked when there is a toolbar to feed.
- const [styleState, setStyleState] = useState(null)
- const [selection, setSelection] = useState(EMPTY_SELECTION)
- const onChangeState = useCallback((e) => setStyleState(e.nativeEvent), [])
- const onChangeSelection = useCallback((e) => setSelection(e.nativeEvent || EMPTY_SELECTION), [])
- // Shared mention pipeline (same fetch/recents/nav as tentap).
- const {
- indicator: mentionIndicator,
- suggestions,
- setTrigger,
- clearTrigger,
- recordMention,
- handlersRef,
- } = useEditorMentions({ fieldName: name, mentions: props.mentions })
- // ---- initial / external value ----
- // EnrichedTextInput's web build puts `defaultValue` in useEditor deps, so a
- // changing defaultValue destroys/recreates TipTap. After destroy, schema is
- // null and getHTML() throws ("Cannot read properties of null (reading 'cached')").
- // Keep defaultValue fixed for the component lifetime; push later updates via setValue.
- const [initialDefaultValue] = useState(() => alignClassesToStyles(unaLinksToMentions(value || '')))
- const lastSetValue = useRef(value)
- // HTML source view text, null while the editor is shown (see toggleSource). The ref
- // mirrors 'open' for effects: external content (a reset after submit, set_content)
- // replaces an open source view, and a closed one gets no no-op update.
- const [sourceHtml, setSourceHtml] = useState(null)
- const sourceOpenRef = useRef(false)
- useEffect(() => {
- if (editorRef.current && value !== lastSetValue.current) {
- lastSetValue.current = value
- callEditor(editorRef, 'setValue', alignClassesToStyles(unaLinksToMentions(value || '')))
- if (sourceOpenRef.current) setSourceHtml(formatSourceHtml(value || ''))
- }
- }, [value])
- useEffect(() => {
- if (!autofocus || !isWeb) return undefined
- const timer = setTimeout(() => callEditor(editorRef, 'focus'), 80)
- return () => clearTimeout(timer)
- }, [autofocus])
- // ---- emitter (focus/blur/set_content/insert_inline_image), same as tentap ----
- useEffect(() => {
- const sub = emitter.addListener(EVENTS.editor, (data) => {
- if (!editorRef.current) return
- switch (data.action) {
- case 'blur':
- if (data.timeout) setTimeout(() => callEditor(editorRef, 'blur'), data.timeout)
- else callEditor(editorRef, 'blur')
- break
- case 'focus':
- if (isWeb) {
- callEditor(editorRef, 'focus')
- } else {
- // iOS: after the keyboard is dismissed the native input can keep a
- // stale first-responder state, so a plain focus() no-ops. Blur first
- // to clear it, then focus to reliably re-summon the keyboard.
- callEditor(editorRef, 'blur')
- setTimeout(() => callEditor(editorRef, 'focus'), 50)
- }
- break
- case 'set_content':
- lastSetValue.current = data.value
- callEditor(editorRef, 'setValue', alignClassesToStyles(unaLinksToMentions(data.value || '')))
- if (sourceOpenRef.current) setSourceHtml(formatSourceHtml(data.value || ''))
- break
- case 'insert_inline_image': {
- if (data.form_name && form_name && data.form_name !== form_name) return
- if (!data.src || isLimitedHtml) return
- const size = fitInlineImageSize(data.width, data.height)
- callEditor(editorRef, 'setImage', data.src, size.width, size.height)
- break
- }
- default:
- break
- }
- })
- return () => sub.remove()
- }, [form_name, isLimitedHtml])
- useEffect(() => {
- if (formState.isSubmitted && kb_stay_open != true) {
- callEditor(editorRef, 'blur')
- }
- }, [formState.isSubmitted, kb_stay_open])
- // ---- mentions ----
- const insertMention = useCallback((user) => {
- const indicator = mentionIndicator || '@'
- // Web: direct TipTap insert (same @-token parse as tentap). Library
- // setMention no-ops when blur ended the MentionPlugin trigger.
- const inserted = tipTap ? insertMentionInTiptap(tipTap, user, indicator) : false
- if (!inserted) {
- editorRef.current?.setMention?.(
- indicator,
- user.label.trim(),
- mentionAttributesForUser(user)
- )
- }
- recordMention(user)
- clearTrigger()
- }, [mentionIndicator, tipTap, recordMention, clearTrigger])
- const dropdownPos = useEnrichedMentionDropdown({
- tipTap,
- containerRef,
- dropdownRef,
- hasSuggestions: suggestions.length > 0,
- setTrigger,
- })
- // Native library callbacks (web derives the trigger from TipTap above).
- const onStartMention = useCallback((indicator) => {
- if (!isWeb) setTrigger('', indicator)
- }, [setTrigger])
- const onChangeMention = useCallback((e) => {
- if (!isWeb) setTrigger(e.text, e.indicator)
- }, [setTrigger])
- const onEndMention = useCallback(() => {
- if (!isWeb) clearTrigger()
- }, [clearTrigger])
- // ---- HTML -> react-hook-form ----
- const isInitialHtmlEmission = useRef(true)
- const onChangeHtml = useCallback((e) => {
- // Library wraps TipTap HTML in <html>...</html>; strip so RHF value matches
- // UNA content and doesn't look like an external edit to the value effect.
- let raw = e?.nativeEvent?.value ?? ''
- raw = raw.replace(/^<html>/i, '').replace(/<\/html>$/i, '')
- // Convert mentions to UNA links, then auto-link any plain URLs/emails the
- // editor didn't catch itself (web has the library's autolink disabled), so
- // saved content has real <a> links on every platform.
- const html = linkifyHtml(mentionsToUnaLinks(raw))
- if (onFocus && html) onFocus()
- const next = normalizeEditorHtml(html, { isLimitedHtml })
- commitEditorHtml({
- next,
- field,
- name,
- resetField,
- isInitialRef: isInitialHtmlEmission,
- })
- lastSetValue.current = next
- }, [field, isLimitedHtml, name, onFocus, resetField])
- // ---- images (paste + toolbar picker) ----
- // Toolbar editors put images in the body by default; html=3 cannot keep <img>.
- // Inline: upload to the editor storage (like UNA's Quill) and insert the link —
- // not via the files field, whose ghosts become attachments with private URLs.
- const pasteImagesMode = form_name
- ? getEditorPasteImagesMode(form_name, isToolBar ? 'inline' : undefined)
- : 'off'
- const isInline = !isLimitedHtml && pasteImagesMode === 'inline'
- // Why the last inline image/video didn't make it into the body (UNA's text when it gave one).
- const [mediaError, setMediaError] = useState(null)
- const showUploadError = useCallback((error) => setMediaError(error || t('Upload failed')), [t])
- const sendImagesToFiles = useCallback((images) => {
- if (!images?.length || pasteImagesMode === 'off') return
- if (isInline) {
- setMediaError(null)
- images.forEach(async (img, index) => {
- // Placeholder: the local blob:/file:// image right away, swapped for the
- // uploaded URL (or dropped on failure). Submit waits on the pending upload.
- const uploadId = `inline-${Date.now()}-${index}`
- const size = fitInlineImageSize(img.width, img.height)
- trackFormUploadStart(form_name, formInstanceId, uploadId)
- callEditor(editorRef, 'setImage', img.uri, size.width, size.height)
- try {
- const { link, error } = await uploadInlineImage(img)
- await swapEditorImageSrc(editorRef, tipTap, img.uri, link)
- if (!link) showUploadError(error)
- } finally {
- trackFormUploadEnd(form_name, formInstanceId, uploadId)
- revokePastedBlobUri(img.uri)
- }
- })
- return
- }
- emitter.emit(EVENTS.form(form_name), {
- action: 'pasted_images',
- images,
- inlinePaste: false,
- })
- }, [form_name, formInstanceId, isInline, pasteImagesMode, tipTap, showUploadError])
- const onInsertImage = useCallback(async () => {
- const images = await pickEditorImages()
- sendImagesToFiles(images)
- }, [sendImagesToFiles])
- // Video: the body keeps its poster image (the editors have no <video>/<iframe>; an
- // image is atomic on every platform); the post view renders it as a player — see
- // splitInlineMedia. Until transcoded the poster link shows UNA's video-na.png.
- const [videoProgress, setVideoProgress] = useState(null)
- const onInsertVideo = useCallback(async () => {
- const video = await pickEditorVideo()
- if (!video) return
- const uploadId = `inline-video-${Date.now()}`
- trackFormUploadStart(form_name, formInstanceId, uploadId)
- setMediaError(null)
- setVideoProgress(0)
- try {
- const { link, error } = await uploadInlineVideo(video, setVideoProgress)
- if (link) {
- const size = video.width && video.height
- ? fitInlineImageSize(video.width, video.height)
- : { width: 720, height: 405 }
- callEditor(editorRef, 'setImage', toVideoPosterSrc(link), size.width, size.height)
- } else {
- showUploadError(error)
- }
- } finally {
- setVideoProgress(null)
- trackFormUploadEnd(form_name, formInstanceId, uploadId)
- }
- }, [form_name, formInstanceId, showUploadError])
- // Library onPasteImages: blob: on web, file:// on native.
- const onPasteImages = useCallback((e) => {
- const imgs = e?.nativeEvent?.images || []
- if (!imgs.length) return
- const images = imgs.map((img, index) => {
- const mimeType = img.type || 'image/png'
- const ext = mimeType.split('/')[1] || 'png'
- return {
- uri: img.uri,
- fileName: `pasted-${Date.now()}-${index}.${ext}`,
- mimeType,
- width: img.width,
- height: img.height,
- }
- })
- sendImagesToFiles(images)
- }, [sendImagesToFiles])
- // ---- Enter routing (desktop only; mobile Enter inserts newline) ----
- const submitOnEnter = isDesktop && (
- isCommentsEditor
- ? (enableSubmitOnEnter || appSetting('comments', 'submit_comment_on_enter'))
- : enableSubmitOnEnter
- )
- // Comment composers stay in newline mode and route Enter in useEnrichedKeyboard.
- const submitBehavior = submitOnEnter && !isCommentsEditor ? 'submit' : 'newline'
- const onSubmitEditing = useCallback(() => {
- if (isCommentsEditor && submitOnEnter) return
- if (onEnterSubmit) onEnterSubmit()
- }, [isCommentsEditor, submitOnEnter, onEnterSubmit])
- const onKeyPress = useEnrichedKeyboard({
- tipTap,
- isCommentsEditor,
- submitOnEnter,
- handlersRef,
- insertMention,
- onEnterSubmit,
- })
- // Same styles as the published post: `htmlStyle` on native, the `u-vanilla-html`
- // class on web (design/styles/global.web.css bridges the library's own CSS).
- const htmlStyle = useEnrichedHtmlStyle({ small: isCommentsEditor })
- const contentClass = isCommentsEditor ? 'u-vanilla-html-small' : 'u-vanilla-html'
- const editorStyle = useMemo(() => ({
- minHeight: initialHeight,
- maxHeight,
- color: editorTextColor,
- fontSize: 16,
- backgroundColor: 'transparent',
- }), [initialHeight, maxHeight, editorTextColor])
- const handleFocus = useCallback(() => {
- if (onFocus) onFocus()
- }, [onFocus])
- // Web: clicks on padding / empty chrome around ProseMirror (toolbar field
- // padding, areas outside the stretched contenteditable) should still focus.
- const onContainerMouseDown = useCallback((e) => {
- if (isDisabled) return
- const target = e?.target
- if (!target?.closest) return
- if (target.closest('.ProseMirror, textarea')) return
- if (target.closest('a, button, [role="button"]')) return
- if (dropdownRef.current?.contains?.(target)) return
- e.preventDefault?.()
- callEditor(editorRef, 'focus')
- }, [isDisabled])
- // ---- HTML source view (toolbar button) ----
- // The editor stays mounted (hidden): recreating TipTap loses its state. Source edits
- // go straight to the form value, so a submit from this view saves them; leaving it
- // loads the HTML back into the editor, which drops what its schema can't hold.
- const isSource = sourceHtml !== null
- const onChangeSource = useCallback((text) => {
- setSourceHtml(text)
- const next = normalizeEditorHtml(text, { isLimitedHtml })
- field.onChange(next)
- lastSetValue.current = next
- }, [field, isLimitedHtml])
- const toggleSource = useCallback(() => {
- if (sourceHtml === null) {
- sourceOpenRef.current = true
- setSourceHtml(formatSourceHtml(field.value || ''))
- return
- }
- sourceOpenRef.current = false
- callEditor(editorRef, 'setValue', alignClassesToStyles(unaLinksToMentions(sourceHtml)))
- setSourceHtml(null)
- setTimeout(() => callEditor(editorRef, 'focus'), 0)
- }, [sourceHtml, field.value])
- const source = useMemo(
- () => (isToolBar && !isLimitedHtml ? { active: isSource, onToggle: toggleSource } : null),
- [isToolBar, isLimitedHtml, isSource, toggleSource]
- )
- const { items: toolbarItems, linkBar } = useEnrichedToolbar({
- editorRef,
- containerRef,
- tipTap,
- styleState,
- selection,
- enabled: isToolBar,
- onInsertImage: pasteImagesMode === 'off' ? undefined : onInsertImage,
- onInsertVideo: isInline ? onInsertVideo : undefined,
- embeds: !isLimitedHtml,
- source,
- })
- const surfaceClass = isToolBar
- ? ' px-3 py-2 bg-input/60 shadow-input-outline dark:shadow-input-outline-deep rounded-xl flex-auto overflow-hidden text-card-foreground '
- : (bg == 'transparent' ? '' : cn(inputSettings.base, inputSettings.size.regular))
- return (
- <View
- ref={containerRef}
- onMouseDown={isWeb ? onContainerMouseDown : undefined}
- className="relative flex-auto web:cursor-text overflow-visible"
- >
- <MentionSuggestionsDropdown
- suggestions={suggestions}
- onSelect={insertMention}
- dropdownRef={dropdownRef}
- className={dropdownPos ? '' : 'bottom-0'}
- style={
- dropdownPos
- ? {
- position: 'absolute',
- left: 0,
- alignSelf: 'flex-start',
- maxHeight: dropdownPos.maxHeight,
- ...(dropdownPos.top != null
- ? { top: dropdownPos.top }
- : { bottom: dropdownPos.bottom }),
- }
- : undefined
- }
- />
- <View className={surfaceClass}>
- {isSource ? (
- <TextInput
- value={sourceHtml}
- onChangeText={onChangeSource}
- multiline
- autoFocus
- autoCapitalize="none"
- autoCorrect={false}
- spellCheck={false}
- editable={!isDisabled}
- textAlignVertical="top"
- accessibilityLabel={t('HTML source')}
- className="font-mono text-sm text-foreground bg-transparent"
- style={{ height: Math.max(initialHeight, maxHeight), ...(isWeb ? { outlineStyle: 'none', resize: 'vertical' } : null) }}
- />
- ) : null}
- <View className={cn(isWeb && contentClass, isSource && 'hidden')}>
- <EnrichedEditorBoundary>
- <EnrichedTextInput
- ref={editorRef}
- defaultValue={initialDefaultValue}
- placeholder={placeholder}
- placeholderTextColor="rgba(120,130,145,1)"
- editable={!isDisabled}
- autoFocus={isWeb ? false : !!autofocus}
- autoCapitalize="none"
- mentionIndicators={MENTION_INDICATORS}
- textShortcuts={isRich ? MARKDOWN_SHORTCUTS : undefined}
- htmlStyle={htmlStyle}
- submitBehavior={submitBehavior}
- style={editorStyle}
- onChangeHtml={onChangeHtml}
- onChangeState={isToolBar ? onChangeState : undefined}
- onChangeSelection={isToolBar ? onChangeSelection : undefined}
- onStartMention={onStartMention}
- onChangeMention={onChangeMention}
- onEndMention={onEndMention}
- onPasteImages={onPasteImages}
- onSubmitEditing={onSubmitEditing}
- onKeyPress={onKeyPress}
- onFocus={handleFocus}
- />
- </EnrichedEditorBoundary>
- </View>
- {embedViews}
- {isToolBar ? (
- <EditorToolbar items={toolbarItems} linkBar={linkBar} />
- ) : null}
- {videoProgress != null ? (
- <Text className="text-xs text-muted-foreground mt-1">
- {t('Uploading video')} {Math.round(videoProgress * 100)}%
- </Text>
- ) : null}
- {mediaError && videoProgress == null ? (
- <Text className="text-xs text-destructive mt-1" accessibilityRole="alert">
- {mediaError}
- </Text>
- ) : null}
- </View>
- </View>
- )
- }
|