index.tsx raw

   1  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
   2  import { parseEditorJsonToText } from '@/lib/tiptap'
   3  import { cn } from '@/lib/utils'
   4  import customEmojiService from '@/services/custom-emoji.service'
   5  import postEditorCache from '@/services/post-editor-cache.service'
   6  import { TEmoji } from '@/types'
   7  import Document from '@tiptap/extension-document'
   8  import { HardBreak } from '@tiptap/extension-hard-break'
   9  import History from '@tiptap/extension-history'
  10  import Paragraph from '@tiptap/extension-paragraph'
  11  import Placeholder from '@tiptap/extension-placeholder'
  12  import Text from '@tiptap/extension-text'
  13  import { TextSelection } from '@tiptap/pm/state'
  14  import { EditorContent, useEditor } from '@tiptap/react'
  15  import { Event } from 'nostr-tools'
  16  import { Dispatch, forwardRef, SetStateAction, useImperativeHandle, useState } from 'react'
  17  import { useTranslation } from 'react-i18next'
  18  import { ClipboardAndDropHandler } from './ClipboardAndDropHandler'
  19  import Emoji from './Emoji'
  20  import emojiSuggestion from './Emoji/suggestion'
  21  import Mention from './Mention'
  22  import mentionSuggestion from './Mention/suggestion'
  23  import Preview from './Preview'
  24  
  25  export type TPostTextareaHandle = {
  26    appendText: (text: string, addNewline?: boolean) => void
  27    insertText: (text: string) => void
  28    insertEmoji: (emoji: string | TEmoji) => void
  29    clear: () => void
  30    replaceText: (text: string) => void
  31  }
  32  
  33  const PostTextarea = forwardRef<
  34    TPostTextareaHandle,
  35    {
  36      text: string
  37      setText: Dispatch<SetStateAction<string>>
  38      defaultContent?: string
  39      parentStuff?: Event | string
  40      onSubmit?: () => void
  41      className?: string
  42      fillHeight?: boolean
  43      onUploadStart?: (file: File, cancel: () => void) => void
  44      onUploadProgress?: (file: File, progress: number) => void
  45      onUploadEnd?: (file: File) => void
  46      placeholder?: string
  47    }
  48  >(
  49    (
  50      {
  51        text = '',
  52        setText,
  53        defaultContent,
  54        parentStuff,
  55        onSubmit,
  56        className,
  57        fillHeight = false,
  58        onUploadStart,
  59        onUploadProgress,
  60        onUploadEnd,
  61        placeholder
  62      },
  63      ref
  64    ) => {
  65      const { t } = useTranslation()
  66      const [tabValue, setTabValue] = useState('edit')
  67      const editor = useEditor({
  68        extensions: [
  69          Document,
  70          Paragraph,
  71          Text,
  72          History,
  73          HardBreak,
  74          Placeholder.configure({
  75            placeholder:
  76              placeholder ??
  77              t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
  78          }),
  79          Emoji.configure({
  80            suggestion: emojiSuggestion
  81          }),
  82          Mention.configure({
  83            suggestion: mentionSuggestion
  84          }),
  85          ClipboardAndDropHandler.configure({
  86            onUploadStart: (file, cancel) => {
  87              onUploadStart?.(file, cancel)
  88            },
  89            onUploadEnd: (file) => onUploadEnd?.(file),
  90            onUploadProgress: (file, p) => onUploadProgress?.(file, p)
  91          })
  92        ],
  93        editorProps: {
  94          attributes: {
  95            class: cn(
  96              'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
  97              className
  98            )
  99          },
 100          handleKeyDown: (_view, event) => {
 101            // Handle Ctrl+Enter or Cmd+Enter for submit
 102            if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
 103              event.preventDefault()
 104              onSubmit?.()
 105              return true
 106            }
 107            return false
 108          },
 109          clipboardTextSerializer(content) {
 110            return parseEditorJsonToText(content.toJSON())
 111          }
 112        },
 113        content: postEditorCache.getPostContentCache({ defaultContent, parentStuff }),
 114        onUpdate(props) {
 115          setText(parseEditorJsonToText(props.editor.getJSON()))
 116          postEditorCache.setPostContentCache({ defaultContent, parentStuff }, props.editor.getJSON())
 117        },
 118        onCreate(props) {
 119          setText(parseEditorJsonToText(props.editor.getJSON()))
 120        }
 121      })
 122  
 123      useImperativeHandle(ref, () => ({
 124        appendText: (text: string, addNewline = false) => {
 125          if (editor) {
 126            let chain = editor
 127              .chain()
 128              .focus()
 129              .command(({ tr, dispatch }) => {
 130                if (dispatch) {
 131                  const endPos = tr.doc.content.size
 132                  const selection = TextSelection.create(tr.doc, endPos)
 133                  tr.setSelection(selection)
 134                  dispatch(tr)
 135                }
 136                return true
 137              })
 138              .insertContent(text)
 139            if (addNewline) {
 140              chain = chain.setHardBreak()
 141            }
 142            chain.run()
 143          }
 144        },
 145        insertText: (text: string) => {
 146          if (editor) {
 147            editor.chain().focus().insertContent(text).run()
 148          }
 149        },
 150        insertEmoji: (emoji: string | TEmoji) => {
 151          if (editor) {
 152            if (typeof emoji === 'string') {
 153              editor.chain().insertContent(emoji).run()
 154            } else {
 155              const emojiNode = editor.schema.nodes.emoji.create({
 156                name: customEmojiService.getEmojiId(emoji)
 157              })
 158              editor.chain().insertContent(emojiNode).insertContent(' ').run()
 159            }
 160          }
 161        },
 162        clear: () => {
 163          if (editor) {
 164            editor.commands.clearContent()
 165            postEditorCache.clearPostCache({ defaultContent, parentStuff })
 166          }
 167        },
 168        replaceText: (text: string) => {
 169          if (editor) {
 170            editor.commands.clearContent()
 171            editor.chain().focus().insertContent(text).run()
 172          }
 173        }
 174      }))
 175  
 176      if (!editor) {
 177        return null
 178      }
 179  
 180      return (
 181        <Tabs
 182          defaultValue="edit"
 183          value={tabValue}
 184          onValueChange={(v) => setTabValue(v)}
 185          className={cn('space-y-2', fillHeight && 'flex flex-col h-full')}
 186        >
 187          <TabsList className="shrink-0">
 188            <TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
 189            <TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
 190          </TabsList>
 191          <TabsContent value="edit" className={cn(fillHeight && 'flex-1 min-h-0 [&_.tiptap]:h-full')}>
 192            <EditorContent className={cn('tiptap', fillHeight && 'h-full')} editor={editor} />
 193          </TabsContent>
 194          <TabsContent
 195            value="preview"
 196            className={cn(fillHeight && 'flex-1 min-h-0 overflow-auto')}
 197            onClick={() => {
 198              setTabValue('edit')
 199              editor.commands.focus()
 200            }}
 201          >
 202            <Preview content={text} className={cn(className, fillHeight && 'h-full')} />
 203          </TabsContent>
 204        </Tabs>
 205      )
 206    }
 207  )
 208  PostTextarea.displayName = 'PostTextarea'
 209  export default PostTextarea
 210