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