mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at thread-bug 487 lines 14 kB view raw
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}