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}