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}