mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useRef} from 'react'
2import {StyleSheet, View} from 'react-native'
3import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
4import {AppBskyRichtextFacet, RichText} from '@atproto/api'
5import {Trans} from '@lingui/macro'
6import {Document} from '@tiptap/extension-document'
7import Hardbreak from '@tiptap/extension-hard-break'
8import History from '@tiptap/extension-history'
9import {Mention} from '@tiptap/extension-mention'
10import {Paragraph} from '@tiptap/extension-paragraph'
11import {Placeholder} from '@tiptap/extension-placeholder'
12import {Text as TiptapText} from '@tiptap/extension-text'
13import {generateJSON} from '@tiptap/html'
14import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
15import EventEmitter from 'eventemitter3'
16
17import {usePalette} from '#/lib/hooks/usePalette'
18import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
19import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
20import {blobToDataUri, isUriImage} from 'lib/media/util'
21import {
22 LinkFacetMatch,
23 suggestLinkCardUri,
24} from 'view/com/composer/text-input/text-input-util'
25import {Portal} from '#/components/Portal'
26import {Text} from '../../util/text/Text'
27import {createSuggestion} from './web/Autocomplete'
28import {Emoji} from './web/EmojiPicker.web'
29import {LinkDecorator} from './web/LinkDecorator'
30import {TagDecorator} from './web/TagDecorator'
31
32export interface TextInputRef {
33 focus: () => void
34 blur: () => void
35 getCursorPosition: () => DOMRect | undefined
36}
37
38interface TextInputProps {
39 richtext: RichText
40 placeholder: string
41 suggestedLinks: Set<string>
42 setRichText: (v: RichText | ((v: RichText) => RichText)) => void
43 onPhotoPasted: (uri: string) => void
44 onPressPublish: (richtext: RichText) => Promise<void>
45 onNewLink: (uri: string) => void
46 onError: (err: string) => void
47}
48
49export const textInputWebEmitter = new EventEmitter()
50
51export const TextInput = React.forwardRef(function TextInputImpl(
52 {
53 richtext,
54 placeholder,
55 setRichText,
56 onPhotoPasted,
57 onPressPublish,
58 onNewLink,
59 }: // onError, TODO
60 TextInputProps,
61 ref,
62) {
63 const autocomplete = useActorAutocompleteFn()
64 const pal = usePalette('default')
65 const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
66
67 const [isDropping, setIsDropping] = React.useState(false)
68
69 const extensions = React.useMemo(
70 () => [
71 Document,
72 LinkDecorator,
73 TagDecorator,
74 Mention.configure({
75 HTMLAttributes: {
76 class: 'mention',
77 },
78 suggestion: createSuggestion({autocomplete}),
79 }),
80 Paragraph,
81 Placeholder.configure({
82 placeholder,
83 }),
84 TiptapText,
85 History,
86 Hardbreak,
87 ],
88 [autocomplete, placeholder],
89 )
90
91 React.useEffect(() => {
92 textInputWebEmitter.addListener('publish', onPressPublish)
93 return () => {
94 textInputWebEmitter.removeListener('publish', onPressPublish)
95 }
96 }, [onPressPublish])
97 React.useEffect(() => {
98 textInputWebEmitter.addListener('photo-pasted', onPhotoPasted)
99 return () => {
100 textInputWebEmitter.removeListener('photo-pasted', onPhotoPasted)
101 }
102 }, [onPhotoPasted])
103
104 React.useEffect(() => {
105 const handleDrop = (event: DragEvent) => {
106 const transfer = event.dataTransfer
107 if (transfer) {
108 const items = transfer.items
109
110 getImageFromUri(items, (uri: string) => {
111 textInputWebEmitter.emit('photo-pasted', uri)
112 })
113 }
114
115 event.preventDefault()
116 setIsDropping(false)
117 }
118 const handleDragEnter = (event: DragEvent) => {
119 const transfer = event.dataTransfer
120
121 event.preventDefault()
122 if (transfer && transfer.types.includes('Files')) {
123 setIsDropping(true)
124 }
125 }
126 const handleDragLeave = (event: DragEvent) => {
127 event.preventDefault()
128 setIsDropping(false)
129 }
130
131 document.body.addEventListener('drop', handleDrop)
132 document.body.addEventListener('dragenter', handleDragEnter)
133 document.body.addEventListener('dragover', handleDragEnter)
134 document.body.addEventListener('dragleave', handleDragLeave)
135
136 return () => {
137 document.body.removeEventListener('drop', handleDrop)
138 document.body.removeEventListener('dragenter', handleDragEnter)
139 document.body.removeEventListener('dragover', handleDragEnter)
140 document.body.removeEventListener('dragleave', handleDragLeave)
141 }
142 }, [setIsDropping])
143
144 const pastSuggestedUris = useRef(new Set<string>())
145 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
146 const editor = useEditor(
147 {
148 extensions,
149 editorProps: {
150 attributes: {
151 class: modeClass,
152 },
153 handlePaste: (view, event) => {
154 const clipboardData = event.clipboardData
155 let preventDefault = false
156
157 if (clipboardData) {
158 if (clipboardData.types.includes('text/html')) {
159 // Rich-text formatting is pasted, try retrieving plain text
160 const text = clipboardData.getData('text/plain')
161 // `pasteText` will invoke this handler again, but `clipboardData` will be null.
162 view.pasteText(text)
163 preventDefault = true
164 }
165 getImageFromUri(clipboardData.items, (uri: string) => {
166 textInputWebEmitter.emit('photo-pasted', uri)
167 })
168 if (preventDefault) {
169 // Return `true` to prevent ProseMirror's default paste behavior.
170 return true
171 }
172 }
173 },
174 handleKeyDown: (_, event) => {
175 if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') {
176 textInputWebEmitter.emit('publish')
177 return true
178 }
179 },
180 },
181 content: generateJSON(richtext.text.toString(), extensions),
182 autofocus: 'end',
183 editable: true,
184 injectCSS: true,
185 onCreate({editor: editorProp}) {
186 // HACK
187 // the 'enter' animation sometimes causes autofocus to fail
188 // (see Composer.web.tsx in shell)
189 // so we wait 200ms (the anim is 150ms) and then focus manually
190 // -prf
191 setTimeout(() => {
192 editorProp.chain().focus('end').run()
193 }, 200)
194 },
195 onUpdate({editor: editorProp}) {
196 const json = editorProp.getJSON()
197 const newText = editorJsonToText(json)
198 const isPaste = window.event?.type === 'paste'
199
200 const newRt = new RichText({text: newText})
201 newRt.detectFacetsWithoutResolution()
202 setRichText(newRt)
203
204 const nextDetectedUris = new Map<string, LinkFacetMatch>()
205 if (newRt.facets) {
206 for (const facet of newRt.facets) {
207 for (const feature of facet.features) {
208 if (AppBskyRichtextFacet.isLink(feature)) {
209 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
210 }
211 }
212 }
213 }
214
215 const suggestedUri = suggestLinkCardUri(
216 isPaste,
217 nextDetectedUris,
218 prevDetectedUris.current,
219 pastSuggestedUris.current,
220 )
221 prevDetectedUris.current = nextDetectedUris
222 if (suggestedUri) {
223 onNewLink(suggestedUri)
224 }
225 },
226 },
227 [modeClass],
228 )
229
230 const onEmojiInserted = React.useCallback(
231 (emoji: Emoji) => {
232 editor?.chain().focus().insertContent(emoji.native).run()
233 },
234 [editor],
235 )
236 React.useEffect(() => {
237 textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
238 return () => {
239 textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
240 }
241 }, [onEmojiInserted])
242
243 React.useImperativeHandle(ref, () => ({
244 focus: () => {}, // TODO
245 blur: () => {}, // TODO
246 getCursorPosition: () => {
247 const pos = editor?.state.selection.$anchor.pos
248 return pos ? editor?.view.coordsAtPos(pos) : undefined
249 },
250 }))
251
252 return (
253 <>
254 <View style={styles.container}>
255 <EditorContent
256 editor={editor}
257 style={{color: pal.text.color as string}}
258 />
259 </View>
260
261 {isDropping && (
262 <Portal>
263 <Animated.View
264 style={styles.dropContainer}
265 entering={FadeIn.duration(80)}
266 exiting={FadeOut.duration(80)}>
267 <View style={[pal.view, pal.border, styles.dropModal]}>
268 <Text
269 type="lg"
270 style={[pal.text, pal.borderDark, styles.dropText]}>
271 <Trans>Drop to add images</Trans>
272 </Text>
273 </View>
274 </Animated.View>
275 </Portal>
276 )}
277 </>
278 )
279})
280
281function editorJsonToText(
282 json: JSONContent,
283 isLastDocumentChild: boolean = false,
284): string {
285 let text = ''
286 if (json.type === 'doc') {
287 if (json.content?.length) {
288 for (let i = 0; i < json.content.length; i++) {
289 const node = json.content[i]
290 const isLastNode = i === json.content.length - 1
291 text += editorJsonToText(node, isLastNode)
292 }
293 }
294 } else if (json.type === 'paragraph') {
295 if (json.content?.length) {
296 for (let i = 0; i < json.content.length; i++) {
297 const node = json.content[i]
298 text += editorJsonToText(node)
299 }
300 }
301 if (!isLastDocumentChild) {
302 text += '\n'
303 }
304 } else if (json.type === 'hardBreak') {
305 text += '\n'
306 } else if (json.type === 'text') {
307 text += json.text || ''
308 } else if (json.type === 'mention') {
309 text += `@${json.attrs?.id || ''}`
310 }
311 return text
312}
313
314const styles = StyleSheet.create({
315 container: {
316 flex: 1,
317 alignSelf: 'flex-start',
318 padding: 5,
319 marginLeft: 8,
320 marginBottom: 10,
321 },
322 dropContainer: {
323 backgroundColor: '#0007',
324 pointerEvents: 'none',
325 alignItems: 'center',
326 justifyContent: 'center',
327 // @ts-ignore web only -prf
328 position: 'fixed',
329 padding: 16,
330 top: 0,
331 bottom: 0,
332 left: 0,
333 right: 0,
334 },
335 dropModal: {
336 // @ts-ignore web only
337 boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
338 padding: 8,
339 borderWidth: 1,
340 borderRadius: 16,
341 },
342 dropText: {
343 paddingVertical: 44,
344 paddingHorizontal: 36,
345 borderStyle: 'dashed',
346 borderRadius: 8,
347 borderWidth: 2,
348 },
349})
350
351function getImageFromUri(
352 items: DataTransferItemList,
353 callback: (uri: string) => void,
354) {
355 for (let index = 0; index < items.length; index++) {
356 const item = items[index]
357 const type = item.type
358
359 if (type === 'text/plain') {
360 item.getAsString(async itemString => {
361 if (isUriImage(itemString)) {
362 const response = await fetch(itemString)
363 const blob = await response.blob()
364
365 if (blob.type.startsWith('image/')) {
366 blobToDataUri(blob).then(callback, err => console.error(err))
367 }
368 }
369 })
370 } else if (type.startsWith('image/')) {
371 const file = item.getAsFile()
372
373 if (file) {
374 blobToDataUri(file).then(callback, err => console.error(err))
375 }
376 }
377 }
378}