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