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 {Fragment, Node, Slice} from '@tiptap/pm/model'
15import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
16
17import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
18import {usePalette} from '#/lib/hooks/usePalette'
19import {blobToDataUri, isUriImage} from '#/lib/media/util'
20import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
21import {
22 LinkFacetMatch,
23 suggestLinkCardUri,
24} from '#/view/com/composer/text-input/text-input-util'
25import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
26import {atoms as a, useAlf} from '#/alf'
27import {normalizeTextStyles} from '#/alf/typography'
28import {Portal} from '#/components/Portal'
29import {Text} from '../../util/text/Text'
30import {createSuggestion} from './web/Autocomplete'
31import {Emoji} from './web/EmojiPicker.web'
32import {LinkDecorator} from './web/LinkDecorator'
33import {TagDecorator} from './web/TagDecorator'
34
35export interface TextInputRef {
36 focus: () => void
37 blur: () => void
38 getCursorPosition: () => DOMRect | undefined
39}
40
41interface TextInputProps {
42 richtext: RichText
43 placeholder: string
44 suggestedLinks: Set<string>
45 webForceMinHeight: boolean
46 hasRightPadding: boolean
47 isActive: boolean
48 setRichText: (v: RichText | ((v: RichText) => RichText)) => void
49 onPhotoPasted: (uri: string) => void
50 onPressPublish: (richtext: RichText) => void
51 onNewLink: (uri: string) => void
52 onError: (err: string) => void
53 onFocus: () => void
54}
55
56export const TextInput = React.forwardRef(function TextInputImpl(
57 {
58 richtext,
59 placeholder,
60 webForceMinHeight,
61 hasRightPadding,
62 isActive,
63 setRichText,
64 onPhotoPasted,
65 onPressPublish,
66 onNewLink,
67 onFocus,
68 }: // onError, TODO
69 TextInputProps,
70 ref,
71) {
72 const {theme: t, fonts} = useAlf()
73 const autocomplete = useActorAutocompleteFn()
74 const pal = usePalette('default')
75 const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
76
77 const [isDropping, setIsDropping] = React.useState(false)
78
79 const extensions = React.useMemo(
80 () => [
81 Document,
82 LinkDecorator,
83 TagDecorator,
84 Mention.configure({
85 HTMLAttributes: {
86 class: 'mention',
87 },
88 suggestion: createSuggestion({autocomplete}),
89 }),
90 Paragraph,
91 Placeholder.configure({
92 placeholder,
93 }),
94 TiptapText,
95 History,
96 Hardbreak,
97 ],
98 [autocomplete, placeholder],
99 )
100
101 React.useEffect(() => {
102 if (!isActive) {
103 return
104 }
105 textInputWebEmitter.addListener('publish', onPressPublish)
106 return () => {
107 textInputWebEmitter.removeListener('publish', onPressPublish)
108 }
109 }, [onPressPublish, isActive])
110
111 React.useEffect(() => {
112 if (!isActive) {
113 return
114 }
115 textInputWebEmitter.addListener('media-pasted', onPhotoPasted)
116 return () => {
117 textInputWebEmitter.removeListener('media-pasted', onPhotoPasted)
118 }
119 }, [isActive, onPhotoPasted])
120
121 React.useEffect(() => {
122 if (!isActive) {
123 return
124 }
125
126 const handleDrop = (event: DragEvent) => {
127 const transfer = event.dataTransfer
128 if (transfer) {
129 const items = transfer.items
130
131 getImageOrVideoFromUri(items, (uri: string) => {
132 textInputWebEmitter.emit('media-pasted', uri)
133 })
134 }
135
136 event.preventDefault()
137 setIsDropping(false)
138 }
139 const handleDragEnter = (event: DragEvent) => {
140 const transfer = event.dataTransfer
141
142 event.preventDefault()
143 if (transfer && transfer.types.includes('Files')) {
144 setIsDropping(true)
145 }
146 }
147 const handleDragLeave = (event: DragEvent) => {
148 event.preventDefault()
149 setIsDropping(false)
150 }
151
152 document.body.addEventListener('drop', handleDrop)
153 document.body.addEventListener('dragenter', handleDragEnter)
154 document.body.addEventListener('dragover', handleDragEnter)
155 document.body.addEventListener('dragleave', handleDragLeave)
156
157 return () => {
158 document.body.removeEventListener('drop', handleDrop)
159 document.body.removeEventListener('dragenter', handleDragEnter)
160 document.body.removeEventListener('dragover', handleDragEnter)
161 document.body.removeEventListener('dragleave', handleDragLeave)
162 }
163 }, [setIsDropping, isActive])
164
165 const pastSuggestedUris = useRef(new Set<string>())
166 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
167 const editor = useEditor(
168 {
169 extensions,
170 coreExtensionOptions: {
171 clipboardTextSerializer: {
172 blockSeparator: '\n',
173 },
174 },
175 onFocus() {
176 onFocus?.()
177 },
178 editorProps: {
179 attributes: {
180 class: modeClass,
181 },
182 clipboardTextParser: (text, context) => {
183 const blocks = text.split(/(?:\r\n?|\n)/)
184 const nodes: Node[] = blocks.map(line => {
185 return Node.fromJSON(
186 context.doc.type.schema,
187 line.length > 0
188 ? {type: 'paragraph', content: [{type: 'text', text: line}]}
189 : {type: 'paragraph', content: []},
190 )
191 })
192
193 const fragment = Fragment.fromArray(nodes)
194 return Slice.maxOpen(fragment)
195 },
196 handlePaste: (view, event) => {
197 const clipboardData = event.clipboardData
198 let preventDefault = false
199
200 if (clipboardData) {
201 if (clipboardData.types.includes('text/html')) {
202 // Rich-text formatting is pasted, try retrieving plain text
203 const text = clipboardData.getData('text/plain')
204 // `pasteText` will invoke this handler again, but `clipboardData` will be null.
205 view.pasteText(text)
206 preventDefault = true
207 }
208 getImageOrVideoFromUri(clipboardData.items, (uri: string) => {
209 textInputWebEmitter.emit('media-pasted', uri)
210 })
211 if (preventDefault) {
212 // Return `true` to prevent ProseMirror's default paste behavior.
213 return true
214 }
215 }
216 },
217 handleKeyDown: (_, event) => {
218 if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') {
219 textInputWebEmitter.emit('publish')
220 return true
221 }
222 },
223 },
224 content: generateJSON(richtext.text.toString(), extensions),
225 autofocus: 'end',
226 editable: true,
227 injectCSS: true,
228 shouldRerenderOnTransaction: false,
229 onCreate({editor: editorProp}) {
230 // HACK
231 // the 'enter' animation sometimes causes autofocus to fail
232 // (see Composer.web.tsx in shell)
233 // so we wait 200ms (the anim is 150ms) and then focus manually
234 // -prf
235 setTimeout(() => {
236 editorProp.chain().focus('end').run()
237 }, 200)
238 },
239 onUpdate({editor: editorProp}) {
240 const json = editorProp.getJSON()
241 const newText = editorJsonToText(json)
242 const isPaste = window.event?.type === 'paste'
243
244 const newRt = new RichText({text: newText})
245 newRt.detectFacetsWithoutResolution()
246 setRichText(newRt)
247
248 const nextDetectedUris = new Map<string, LinkFacetMatch>()
249 if (newRt.facets) {
250 for (const facet of newRt.facets) {
251 for (const feature of facet.features) {
252 if (AppBskyRichtextFacet.isLink(feature)) {
253 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
254 }
255 }
256 }
257 }
258
259 const suggestedUri = suggestLinkCardUri(
260 isPaste,
261 nextDetectedUris,
262 prevDetectedUris.current,
263 pastSuggestedUris.current,
264 )
265 prevDetectedUris.current = nextDetectedUris
266 if (suggestedUri) {
267 onNewLink(suggestedUri)
268 }
269 },
270 },
271 [modeClass],
272 )
273
274 const onEmojiInserted = React.useCallback(
275 (emoji: Emoji) => {
276 editor?.chain().focus().insertContent(emoji.native).run()
277 },
278 [editor],
279 )
280 React.useEffect(() => {
281 if (!isActive) {
282 return
283 }
284 textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
285 return () => {
286 textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
287 }
288 }, [onEmojiInserted, isActive])
289
290 React.useImperativeHandle(ref, () => ({
291 focus: () => {
292 editor?.chain().focus()
293 },
294 blur: () => {
295 editor?.chain().blur()
296 },
297 getCursorPosition: () => {
298 const pos = editor?.state.selection.$anchor.pos
299 return pos ? editor?.view.coordsAtPos(pos) : undefined
300 },
301 }))
302
303 const inputStyle = React.useMemo(() => {
304 const style = normalizeTextStyles(
305 [a.text_lg, a.leading_snug, t.atoms.text],
306 {
307 fontScale: fonts.scaleMultiplier,
308 fontFamily: fonts.family,
309 flags: {},
310 },
311 )
312 /*
313 * TipTap component isn't a RN View and while it seems to convert
314 * `fontSize` to `px`, it doesn't convert `lineHeight`.
315 *
316 * `lineHeight` should always be defined here, this is defensive.
317 */
318 style.lineHeight = style.lineHeight
319 ? ((style.lineHeight + 'px') as unknown as number)
320 : undefined
321 style.minHeight = webForceMinHeight ? 140 : undefined
322 return style
323 }, [t, fonts, webForceMinHeight])
324
325 return (
326 <>
327 <View style={[styles.container, hasRightPadding && styles.rightPadding]}>
328 {/* @ts-ignore inputStyle is fine */}
329 <EditorContent editor={editor} style={inputStyle} />
330 </View>
331
332 {isDropping && (
333 <Portal>
334 <Animated.View
335 style={styles.dropContainer}
336 entering={FadeIn.duration(80)}
337 exiting={FadeOut.duration(80)}>
338 <View style={[pal.view, pal.border, styles.dropModal]}>
339 <Text
340 type="lg"
341 style={[pal.text, pal.borderDark, styles.dropText]}>
342 <Trans>Drop to add images</Trans>
343 </Text>
344 </View>
345 </Animated.View>
346 </Portal>
347 )}
348 </>
349 )
350})
351
352function editorJsonToText(
353 json: JSONContent,
354 isLastDocumentChild: boolean = false,
355): string {
356 let text = ''
357 if (json.type === 'doc') {
358 if (json.content?.length) {
359 for (let i = 0; i < json.content.length; i++) {
360 const node = json.content[i]
361 const isLastNode = i === json.content.length - 1
362 text += editorJsonToText(node, isLastNode)
363 }
364 }
365 } else if (json.type === 'paragraph') {
366 if (json.content?.length) {
367 for (let i = 0; i < json.content.length; i++) {
368 const node = json.content[i]
369 text += editorJsonToText(node)
370 }
371 }
372 if (!isLastDocumentChild) {
373 text += '\n'
374 }
375 } else if (json.type === 'hardBreak') {
376 text += '\n'
377 } else if (json.type === 'text') {
378 text += json.text || ''
379 } else if (json.type === 'mention') {
380 text += `@${json.attrs?.id || ''}`
381 }
382 return text
383}
384
385const styles = StyleSheet.create({
386 container: {
387 flex: 1,
388 alignSelf: 'flex-start',
389 padding: 5,
390 marginLeft: 8,
391 marginBottom: 10,
392 },
393 rightPadding: {
394 paddingRight: 32,
395 },
396 dropContainer: {
397 backgroundColor: '#0007',
398 pointerEvents: 'none',
399 alignItems: 'center',
400 justifyContent: 'center',
401 // @ts-ignore web only -prf
402 position: 'fixed',
403 padding: 16,
404 top: 0,
405 bottom: 0,
406 left: 0,
407 right: 0,
408 },
409 dropModal: {
410 // @ts-ignore web only
411 boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
412 padding: 8,
413 borderWidth: 1,
414 borderRadius: 16,
415 },
416 dropText: {
417 paddingVertical: 44,
418 paddingHorizontal: 36,
419 borderStyle: 'dashed',
420 borderRadius: 8,
421 borderWidth: 2,
422 },
423})
424
425function getImageOrVideoFromUri(
426 items: DataTransferItemList,
427 callback: (uri: string) => void,
428) {
429 for (let index = 0; index < items.length; index++) {
430 const item = items[index]
431 const type = item.type
432
433 if (type === 'text/plain') {
434 item.getAsString(async itemString => {
435 if (isUriImage(itemString)) {
436 const response = await fetch(itemString)
437 const blob = await response.blob()
438
439 if (blob.type.startsWith('image/')) {
440 blobToDataUri(blob).then(callback, err => console.error(err))
441 }
442
443 if (blob.type.startsWith('video/')) {
444 blobToDataUri(blob).then(callback, err => console.error(err))
445 }
446 }
447 })
448 } else if (type.startsWith('image/')) {
449 const file = item.getAsFile()
450
451 if (file) {
452 blobToDataUri(file).then(callback, err => console.error(err))
453 }
454 } else if (type.startsWith('video/')) {
455 const file = item.getAsFile()
456
457 if (file) {
458 blobToDataUri(file).then(callback, err => console.error(err))
459 }
460 }
461 }
462}