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