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