Bluesky app fork with some witchin' additions 馃挮
at main 569 lines 17 kB view raw
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, UnicodeString} 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 {splitGraphemes} from 'unicode-segmenter/grapheme' 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 {Decorator} from './web/Decorator' 41 42export function TextInput({ 43 ref, 44 richtext, 45 placeholder, 46 webForceMinHeight, 47 hasRightPadding, 48 isActive, 49 setRichText, 50 onPhotoPasted, 51 onPressPublish, 52 onNewLink, 53 onFocus, 54 autoFocus, 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 Mention.configure({ 67 HTMLAttributes: { 68 class: 'mention', 69 }, 70 suggestion: createSuggestion({autocomplete, autocompleteRef}), 71 }), 72 Paragraph, 73 Placeholder.configure({ 74 placeholder, 75 }), 76 TiptapText, 77 History, 78 Hardbreak, 79 Decorator, 80 ], 81 [autocomplete, placeholder], 82 ) 83 84 useEffect(() => { 85 if (!isActive) { 86 return 87 } 88 textInputWebEmitter.addListener('publish', onPressPublish) 89 return () => { 90 textInputWebEmitter.removeListener('publish', onPressPublish) 91 } 92 }, [onPressPublish, isActive]) 93 94 useEffect(() => { 95 if (!isActive) { 96 return 97 } 98 textInputWebEmitter.addListener('media-pasted', onPhotoPasted) 99 return () => { 100 textInputWebEmitter.removeListener('media-pasted', onPhotoPasted) 101 } 102 }, [isActive, onPhotoPasted]) 103 104 useEffect(() => { 105 if (!isActive) { 106 return 107 } 108 109 const handleDrop = (event: DragEvent) => { 110 const transfer = event.dataTransfer 111 if (transfer) { 112 const items = transfer.items 113 114 getImageOrVideoFromUri(items, (uri: string) => { 115 textInputWebEmitter.emit('media-pasted', uri) 116 }) 117 } 118 119 event.preventDefault() 120 setIsDropping(false) 121 } 122 const handleDragEnter = (event: DragEvent) => { 123 const transfer = event.dataTransfer 124 125 event.preventDefault() 126 if (transfer && transfer.types.includes('Files')) { 127 setIsDropping(true) 128 } 129 } 130 const handleDragLeave = (event: DragEvent) => { 131 event.preventDefault() 132 setIsDropping(false) 133 } 134 135 document.body.addEventListener('drop', handleDrop) 136 document.body.addEventListener('dragenter', handleDragEnter) 137 document.body.addEventListener('dragover', handleDragEnter) 138 document.body.addEventListener('dragleave', handleDragLeave) 139 140 return () => { 141 document.body.removeEventListener('drop', handleDrop) 142 document.body.removeEventListener('dragenter', handleDragEnter) 143 document.body.removeEventListener('dragover', handleDragEnter) 144 document.body.removeEventListener('dragleave', handleDragLeave) 145 } 146 }, [setIsDropping, isActive]) 147 148 const pastSuggestedUris = useRef(new Set<string>()) 149 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>()) 150 const editor = useEditor( 151 { 152 extensions, 153 coreExtensionOptions: { 154 clipboardTextSerializer: { 155 blockSeparator: '\n', 156 }, 157 }, 158 onFocus() { 159 onFocus?.() 160 }, 161 editorProps: { 162 attributes: { 163 class: modeClass, 164 }, 165 clipboardTextParser: (text, context) => { 166 const blocks = text.split(/(?:\r\n?|\n)/) 167 const nodes: Node[] = blocks.map(line => { 168 return Node.fromJSON( 169 context.doc.type.schema, 170 line.length > 0 171 ? {type: 'paragraph', content: [{type: 'text', text: line}]} 172 : {type: 'paragraph', content: []}, 173 ) 174 }) 175 176 const fragment = Fragment.fromArray(nodes) 177 return Slice.maxOpen(fragment) 178 }, 179 handlePaste: (view, event) => { 180 const clipboardData = event.clipboardData 181 let preventDefault = false 182 183 if (clipboardData) { 184 if (clipboardData.types.includes('text/html')) { 185 // Rich-text formatting is pasted, try retrieving plain text 186 const text = clipboardData.getData('text/plain') 187 // `pasteText` will invoke this handler again, but `clipboardData` will be null. 188 view.pasteText(text) 189 preventDefault = true 190 } 191 getImageOrVideoFromUri(clipboardData.items, (uri: string) => { 192 textInputWebEmitter.emit('media-pasted', uri) 193 }) 194 if (preventDefault) { 195 // Return `true` to prevent ProseMirror's default paste behavior. 196 return true 197 } 198 } 199 }, 200 handleKeyDown: (view, event) => { 201 if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') { 202 textInputWebEmitter.emit('publish') 203 return true 204 } 205 206 if ( 207 event.code === 'Backspace' && 208 !(event.metaKey || event.altKey || event.ctrlKey) 209 ) { 210 const isNotSelection = view.state.selection.empty 211 if (isNotSelection) { 212 const cursorPosition = view.state.selection.$anchor.pos 213 const textBefore = view.state.doc.textBetween( 214 0, 215 cursorPosition, 216 // important - use \n as a block separator, otherwise 217 // all the lines get mushed together -sfn 218 '\n', 219 ) 220 const graphemes = [...splitGraphemes(textBefore)] 221 222 if (graphemes.length > 0) { 223 const lastGrapheme = graphemes[graphemes.length - 1] 224 // deleteRange doesn't work on newlines, because tiptap 225 // treats them as separate 'blocks' and we're using \n 226 // as a stand-in. bail out if the last grapheme is a newline 227 // to let the default behavior handle it -sfn 228 if (lastGrapheme !== '\n') { 229 // otherwise, delete the last grapheme using deleteRange, 230 // so that emojis are deleted as a whole 231 const deleteFrom = cursorPosition - lastGrapheme.length 232 editor?.commands.deleteRange({ 233 from: deleteFrom, 234 to: cursorPosition, 235 }) 236 return true 237 } 238 } 239 } 240 } 241 }, 242 }, 243 content: generateJSON(richTextToHTML(richtext), extensions, { 244 preserveWhitespace: 'full', 245 }), 246 autofocus: autoFocus ? 'end' : null, 247 editable: true, 248 injectCSS: true, 249 shouldRerenderOnTransaction: false, 250 onUpdate({editor: editorProp}) { 251 const json = editorProp.getJSON() 252 const newText = editorJsonToText(json) 253 const isPaste = window.event?.type === 'paste' 254 255 const newRt = new RichText({text: newText}) 256 newRt.detectFacetsWithoutResolution() 257 258 const markdownFacets: AppBskyRichtextFacet.Main[] = [] 259 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g 260 let match 261 while ((match = regex.exec(newText)) !== null) { 262 const [fullMatch, _linkText, linkUrl] = match 263 const matchStart = match.index 264 const matchEnd = matchStart + fullMatch.length 265 const prefix = newText.slice(0, matchStart) 266 const matchStr = newText.slice(matchStart, matchEnd) 267 const byteStart = new UnicodeString(prefix).length 268 const byteEnd = byteStart + new UnicodeString(matchStr).length 269 270 let validUrl = linkUrl 271 if ( 272 !validUrl.startsWith('http://') && 273 !validUrl.startsWith('https://') && 274 !validUrl.startsWith('mailto:') 275 ) { 276 validUrl = `https://${validUrl}` 277 } 278 279 markdownFacets.push({ 280 index: {byteStart, byteEnd}, 281 features: [{$type: 'app.bsky.richtext.facet#link', uri: validUrl}], 282 }) 283 } 284 285 if (markdownFacets.length > 0) { 286 const nonOverlapping = (newRt.facets || []).filter(f => { 287 return !markdownFacets.some(mf => { 288 return ( 289 (f.index.byteStart >= mf.index.byteStart && 290 f.index.byteStart < mf.index.byteEnd) || 291 (f.index.byteEnd > mf.index.byteStart && 292 f.index.byteEnd <= mf.index.byteEnd) || 293 (mf.index.byteStart >= f.index.byteStart && 294 mf.index.byteStart < f.index.byteEnd) 295 ) 296 }) 297 }) 298 newRt.facets = [...nonOverlapping, ...markdownFacets].sort( 299 (a, b) => a.index.byteStart - b.index.byteStart, 300 ) 301 } 302 303 setRichText(newRt) 304 305 const nextDetectedUris = new Map<string, LinkFacetMatch>() 306 if (newRt.facets) { 307 for (const facet of newRt.facets) { 308 for (const feature of facet.features) { 309 if (AppBskyRichtextFacet.isLink(feature)) { 310 nextDetectedUris.set(feature.uri, {facet, rt: newRt}) 311 } 312 } 313 } 314 } 315 316 const suggestedUri = suggestLinkCardUri( 317 isPaste, 318 nextDetectedUris, 319 prevDetectedUris.current, 320 pastSuggestedUris.current, 321 ) 322 prevDetectedUris.current = nextDetectedUris 323 if (suggestedUri) { 324 onNewLink(suggestedUri) 325 } 326 }, 327 }, 328 [modeClass], 329 ) 330 331 const onEmojiInserted = useCallback( 332 (emoji: Emoji) => { 333 editor?.chain().focus().insertContent(emoji.native).run() 334 }, 335 [editor], 336 ) 337 useEffect(() => { 338 if (!isActive) { 339 return 340 } 341 textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) 342 return () => { 343 textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) 344 } 345 }, [onEmojiInserted, isActive]) 346 347 useImperativeHandle(ref, () => ({ 348 focus: () => { 349 editor?.chain().focus() 350 }, 351 blur: () => { 352 editor?.chain().blur() 353 }, 354 getCursorPosition: () => { 355 const pos = editor?.state.selection.$anchor.pos 356 return pos ? editor?.view.coordsAtPos(pos) : undefined 357 }, 358 maybeClosePopup: () => autocompleteRef.current?.maybeClose() ?? false, 359 })) 360 361 const inputStyle = useMemo(() => { 362 const style = normalizeTextStyles( 363 [a.text_lg, a.leading_snug, t.atoms.text], 364 { 365 fontScale: fonts.scaleMultiplier, 366 fontFamily: fonts.family, 367 flags: {}, 368 }, 369 ) 370 /* 371 * TipTap component isn't a RN View and while it seems to convert 372 * `fontSize` to `px`, it doesn't convert `lineHeight`. 373 * 374 * `lineHeight` should always be defined here, this is defensive. 375 */ 376 style.lineHeight = style.lineHeight 377 ? ((style.lineHeight + 'px') as unknown as number) 378 : undefined 379 style.minHeight = webForceMinHeight ? 140 : undefined 380 return style 381 }, [t, fonts, webForceMinHeight]) 382 383 return ( 384 <> 385 <View 386 style={[ 387 styles.container, 388 hasRightPadding && styles.rightPadding, 389 { 390 // @ts-ignore 391 '--mention-color': t.palette.primary_500, 392 }, 393 ]}> 394 {/* @ts-ignore inputStyle is fine */} 395 <EditorContent editor={editor} style={inputStyle} /> 396 </View> 397 398 {isDropping && ( 399 <Portal> 400 <Animated.View 401 style={styles.dropContainer} 402 entering={FadeIn.duration(80)} 403 exiting={FadeOut.duration(80)}> 404 <View 405 style={[ 406 t.atoms.bg, 407 t.atoms.border_contrast_low, 408 styles.dropModal, 409 ]}> 410 <Text 411 style={[ 412 a.text_lg, 413 a.font_semi_bold, 414 t.atoms.text_contrast_medium, 415 t.atoms.border_contrast_high, 416 styles.dropText, 417 ]}> 418 <Trans>Drop to add images</Trans> 419 </Text> 420 </View> 421 </Animated.View> 422 </Portal> 423 )} 424 </> 425 ) 426} 427 428/** 429 * Helper function to initialise the editor with RichText, which expects HTML 430 * 431 * All the extensions are able to initialise themselves from plain text, *except* 432 * for the Mention extension - we need to manually convert it into a `<span>` element 433 * 434 * It also escapes HTML characters 435 */ 436function richTextToHTML(richtext: RichText): string { 437 let html = '' 438 439 for (const segment of richtext.segments()) { 440 if (segment.mention) { 441 html += `<span data-type="mention" data-id="${escapeHTML(segment.mention.did)}"></span>` 442 } else { 443 html += escapeHTML(segment.text) 444 } 445 } 446 447 return html 448} 449 450function escapeHTML(str: string): string { 451 return str 452 .replace(/&/g, '&amp;') 453 .replace(/</g, '&lt;') 454 .replace(/>/g, '&gt;') 455 .replace(/"/g, '&quot;') 456 .replace(/\n/g, '<br/>') 457} 458 459function editorJsonToText( 460 json: JSONContent, 461 isLastDocumentChild: boolean = false, 462): string { 463 let text = '' 464 if (json.type === 'doc') { 465 if (json.content?.length) { 466 for (let i = 0; i < json.content.length; i++) { 467 const node = json.content[i] 468 const isLastNode = i === json.content.length - 1 469 text += editorJsonToText(node, isLastNode) 470 } 471 } 472 } else if (json.type === 'paragraph') { 473 if (json.content?.length) { 474 for (let i = 0; i < json.content.length; i++) { 475 const node = json.content[i] 476 text += editorJsonToText(node) 477 } 478 } 479 if (!isLastDocumentChild) { 480 text += '\n' 481 } 482 } else if (json.type === 'hardBreak') { 483 text += '\n' 484 } else if (json.type === 'text') { 485 text += json.text || '' 486 } else if (json.type === 'mention') { 487 text += `@${json.attrs?.id || ''}` 488 } 489 return text 490} 491 492const styles = StyleSheet.create({ 493 container: { 494 flex: 1, 495 alignSelf: 'flex-start', 496 padding: 5, 497 marginLeft: 8, 498 marginBottom: 10, 499 }, 500 rightPadding: { 501 paddingRight: 32, 502 }, 503 dropContainer: { 504 backgroundColor: '#0007', 505 pointerEvents: 'none', 506 alignItems: 'center', 507 justifyContent: 'center', 508 // @ts-ignore web only -prf 509 position: 'fixed', 510 padding: 16, 511 top: 0, 512 bottom: 0, 513 left: 0, 514 right: 0, 515 }, 516 dropModal: { 517 // @ts-ignore web only 518 boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px', 519 padding: 8, 520 borderWidth: 1, 521 borderRadius: 16, 522 }, 523 dropText: { 524 paddingVertical: 44, 525 paddingHorizontal: 36, 526 borderStyle: 'dashed', 527 borderRadius: 8, 528 borderWidth: 2, 529 }, 530}) 531 532function getImageOrVideoFromUri( 533 items: DataTransferItemList, 534 callback: (uri: string) => void, 535) { 536 for (let index = 0; index < items.length; index++) { 537 const item = items[index] 538 const type = item.type 539 540 if (type === 'text/plain') { 541 item.getAsString(async itemString => { 542 if (isUriImage(itemString)) { 543 const response = await fetch(itemString) 544 const blob = await response.blob() 545 546 if (blob.type.startsWith('image/')) { 547 blobToDataUri(blob).then(callback, err => console.error(err)) 548 } 549 550 if (blob.type.startsWith('video/')) { 551 blobToDataUri(blob).then(callback, err => console.error(err)) 552 } 553 } 554 }) 555 } else if (type.startsWith('image/')) { 556 const file = item.getAsFile() 557 558 if (file) { 559 blobToDataUri(file).then(callback, err => console.error(err)) 560 } 561 } else if (type.startsWith('video/')) { 562 const file = item.getAsFile() 563 564 if (file) { 565 blobToDataUri(file).then(callback, err => console.error(err)) 566 } 567 } 568 } 569}