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: (_, event) => {
154 const items = event.clipboardData?.items
155
156 if (items === undefined) {
157 return
158 }
159
160 getImageFromUri(items, (uri: string) => {
161 textInputWebEmitter.emit('photo-pasted', uri)
162 })
163 },
164 handleKeyDown: (_, event) => {
165 if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') {
166 textInputWebEmitter.emit('publish')
167 return true
168 }
169 },
170 },
171 content: generateJSON(richtext.text.toString(), extensions),
172 autofocus: 'end',
173 editable: true,
174 injectCSS: true,
175 onCreate({editor: editorProp}) {
176 // HACK
177 // the 'enter' animation sometimes causes autofocus to fail
178 // (see Composer.web.tsx in shell)
179 // so we wait 200ms (the anim is 150ms) and then focus manually
180 // -prf
181 setTimeout(() => {
182 editorProp.chain().focus('end').run()
183 }, 200)
184 },
185 onUpdate({editor: editorProp}) {
186 const json = editorProp.getJSON()
187 const newText = editorJsonToText(json)
188 const isPaste = window.event?.type === 'paste'
189
190 const newRt = new RichText({text: newText})
191 newRt.detectFacetsWithoutResolution()
192 setRichText(newRt)
193
194 const nextDetectedUris = new Map<string, LinkFacetMatch>()
195 if (newRt.facets) {
196 for (const facet of newRt.facets) {
197 for (const feature of facet.features) {
198 if (AppBskyRichtextFacet.isLink(feature)) {
199 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
200 }
201 }
202 }
203 }
204
205 const suggestedUri = suggestLinkCardUri(
206 isPaste,
207 nextDetectedUris,
208 prevDetectedUris.current,
209 pastSuggestedUris.current,
210 )
211 prevDetectedUris.current = nextDetectedUris
212 if (suggestedUri) {
213 onNewLink(suggestedUri)
214 }
215 },
216 },
217 [modeClass],
218 )
219
220 const onEmojiInserted = React.useCallback(
221 (emoji: Emoji) => {
222 editor?.chain().focus().insertContent(emoji.native).run()
223 },
224 [editor],
225 )
226 React.useEffect(() => {
227 textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
228 return () => {
229 textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
230 }
231 }, [onEmojiInserted])
232
233 React.useImperativeHandle(ref, () => ({
234 focus: () => {}, // TODO
235 blur: () => {}, // TODO
236 getCursorPosition: () => {
237 const pos = editor?.state.selection.$anchor.pos
238 return pos ? editor?.view.coordsAtPos(pos) : undefined
239 },
240 }))
241
242 return (
243 <>
244 <View style={styles.container}>
245 <EditorContent
246 editor={editor}
247 style={{color: pal.text.color as string}}
248 />
249 </View>
250
251 {isDropping && (
252 <Portal>
253 <Animated.View
254 style={styles.dropContainer}
255 entering={FadeIn.duration(80)}
256 exiting={FadeOut.duration(80)}>
257 <View style={[pal.view, pal.border, styles.dropModal]}>
258 <Text
259 type="lg"
260 style={[pal.text, pal.borderDark, styles.dropText]}>
261 <Trans>Drop to add images</Trans>
262 </Text>
263 </View>
264 </Animated.View>
265 </Portal>
266 )}
267 </>
268 )
269})
270
271function editorJsonToText(
272 json: JSONContent,
273 isLastDocumentChild: boolean = false,
274): string {
275 let text = ''
276 if (json.type === 'doc') {
277 if (json.content?.length) {
278 for (let i = 0; i < json.content.length; i++) {
279 const node = json.content[i]
280 const isLastNode = i === json.content.length - 1
281 text += editorJsonToText(node, isLastNode)
282 }
283 }
284 } else if (json.type === 'paragraph') {
285 if (json.content?.length) {
286 for (let i = 0; i < json.content.length; i++) {
287 const node = json.content[i]
288 text += editorJsonToText(node)
289 }
290 }
291 if (!isLastDocumentChild) {
292 text += '\n'
293 }
294 } else if (json.type === 'hardBreak') {
295 text += '\n'
296 } else if (json.type === 'text') {
297 text += json.text || ''
298 } else if (json.type === 'mention') {
299 text += `@${json.attrs?.id || ''}`
300 }
301 return text
302}
303
304const styles = StyleSheet.create({
305 container: {
306 flex: 1,
307 alignSelf: 'flex-start',
308 padding: 5,
309 marginLeft: 8,
310 marginBottom: 10,
311 },
312 dropContainer: {
313 backgroundColor: '#0007',
314 pointerEvents: 'none',
315 alignItems: 'center',
316 justifyContent: 'center',
317 // @ts-ignore web only -prf
318 position: 'fixed',
319 padding: 16,
320 top: 0,
321 bottom: 0,
322 left: 0,
323 right: 0,
324 },
325 dropModal: {
326 // @ts-ignore web only
327 boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
328 padding: 8,
329 borderWidth: 1,
330 borderRadius: 16,
331 },
332 dropText: {
333 paddingVertical: 44,
334 paddingHorizontal: 36,
335 borderStyle: 'dashed',
336 borderRadius: 8,
337 borderWidth: 2,
338 },
339})
340
341function getImageFromUri(
342 items: DataTransferItemList,
343 callback: (uri: string) => void,
344) {
345 for (let index = 0; index < items.length; index++) {
346 const item = items[index]
347 const type = item.type
348
349 if (type === 'text/plain') {
350 item.getAsString(async itemString => {
351 if (isUriImage(itemString)) {
352 const response = await fetch(itemString)
353 const blob = await response.blob()
354
355 if (blob.type.startsWith('image/')) {
356 blobToDataUri(blob).then(callback, err => console.error(err))
357 }
358 }
359 })
360 } else if (type.startsWith('image/')) {
361 const file = item.getAsFile()
362
363 if (file) {
364 blobToDataUri(file).then(callback, err => console.error(err))
365 }
366 }
367 }
368}