Bluesky app fork with some witchin' additions 馃挮
at readme-update 557 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 {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( 215 0, 216 cursorPosition, 217 // important - use \n as a block separator, otherwise 218 // all the lines get mushed together -sfn 219 '\n', 220 ) 221 const graphemes = [...splitGraphemes(textBefore)] 222 223 if (graphemes.length > 0) { 224 const lastGrapheme = graphemes[graphemes.length - 1] 225 // deleteRange doesn't work on newlines, because tiptap 226 // treats them as separate 'blocks' and we're using \n 227 // as a stand-in. bail out if the last grapheme is a newline 228 // to let the default behavior handle it -sfn 229 if (lastGrapheme !== '\n') { 230 // otherwise, delete the last grapheme using deleteRange, 231 // so that emojis are deleted as a whole 232 const deleteFrom = cursorPosition - lastGrapheme.length 233 editor?.commands.deleteRange({ 234 from: deleteFrom, 235 to: cursorPosition, 236 }) 237 return true 238 } 239 } 240 } 241 } 242 }, 243 }, 244 content: generateJSON(textToHtml(richtext.text.toString()), extensions, { 245 preserveWhitespace: 'full', 246 }), 247 autofocus: 'end', 248 editable: true, 249 injectCSS: true, 250 shouldRerenderOnTransaction: false, 251 onCreate({editor: editorProp}) { 252 // HACK 253 // the 'enter' animation sometimes causes autofocus to fail 254 // (see Composer.web.tsx in shell) 255 // so we wait 200ms (the anim is 150ms) and then focus manually 256 // -prf 257 setTimeout(() => { 258 editorProp.chain().focus('end').run() 259 }, 200) 260 }, 261 onUpdate({editor: editorProp}) { 262 const json = editorProp.getJSON() 263 const newText = editorJsonToText(json) 264 const isPaste = window.event?.type === 'paste' 265 266 const newRt = new RichText({text: newText}) 267 newRt.detectFacetsWithoutResolution() 268 269 const markdownFacets: AppBskyRichtextFacet.Main[] = [] 270 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g 271 let match 272 while ((match = regex.exec(newText)) !== null) { 273 const [fullMatch, _linkText, linkUrl] = match 274 const matchStart = match.index 275 const matchEnd = matchStart + fullMatch.length 276 const prefix = newText.slice(0, matchStart) 277 const matchStr = newText.slice(matchStart, matchEnd) 278 const byteStart = new UnicodeString(prefix).length 279 const byteEnd = byteStart + new UnicodeString(matchStr).length 280 281 let validUrl = linkUrl 282 if ( 283 !validUrl.startsWith('http://') && 284 !validUrl.startsWith('https://') && 285 !validUrl.startsWith('mailto:') 286 ) { 287 validUrl = `https://${validUrl}` 288 } 289 290 markdownFacets.push({ 291 index: {byteStart, byteEnd}, 292 features: [{$type: 'app.bsky.richtext.facet#link', uri: validUrl}], 293 }) 294 } 295 296 if (markdownFacets.length > 0) { 297 const nonOverlapping = (newRt.facets || []).filter(f => { 298 return !markdownFacets.some(mf => { 299 return ( 300 (f.index.byteStart >= mf.index.byteStart && 301 f.index.byteStart < mf.index.byteEnd) || 302 (f.index.byteEnd > mf.index.byteStart && 303 f.index.byteEnd <= mf.index.byteEnd) || 304 (mf.index.byteStart >= f.index.byteStart && 305 mf.index.byteStart < f.index.byteEnd) 306 ) 307 }) 308 }) 309 newRt.facets = [...nonOverlapping, ...markdownFacets].sort( 310 (a, b) => a.index.byteStart - b.index.byteStart, 311 ) 312 } 313 314 setRichText(newRt) 315 316 const nextDetectedUris = new Map<string, LinkFacetMatch>() 317 if (newRt.facets) { 318 for (const facet of newRt.facets) { 319 for (const feature of facet.features) { 320 if (AppBskyRichtextFacet.isLink(feature)) { 321 nextDetectedUris.set(feature.uri, {facet, rt: newRt}) 322 } 323 } 324 } 325 } 326 327 const suggestedUri = suggestLinkCardUri( 328 isPaste, 329 nextDetectedUris, 330 prevDetectedUris.current, 331 pastSuggestedUris.current, 332 ) 333 prevDetectedUris.current = nextDetectedUris 334 if (suggestedUri) { 335 onNewLink(suggestedUri) 336 } 337 }, 338 }, 339 [modeClass], 340 ) 341 342 const onEmojiInserted = useCallback( 343 (emoji: Emoji) => { 344 editor?.chain().focus().insertContent(emoji.native).run() 345 }, 346 [editor], 347 ) 348 useEffect(() => { 349 if (!isActive) { 350 return 351 } 352 textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) 353 return () => { 354 textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) 355 } 356 }, [onEmojiInserted, isActive]) 357 358 useImperativeHandle(ref, () => ({ 359 focus: () => { 360 editor?.chain().focus() 361 }, 362 blur: () => { 363 editor?.chain().blur() 364 }, 365 getCursorPosition: () => { 366 const pos = editor?.state.selection.$anchor.pos 367 return pos ? editor?.view.coordsAtPos(pos) : undefined 368 }, 369 maybeClosePopup: () => autocompleteRef.current?.maybeClose() ?? false, 370 })) 371 372 const inputStyle = useMemo(() => { 373 const style = normalizeTextStyles( 374 [a.text_lg, a.leading_snug, t.atoms.text], 375 { 376 fontScale: fonts.scaleMultiplier, 377 fontFamily: fonts.family, 378 flags: {}, 379 }, 380 ) 381 /* 382 * TipTap component isn't a RN View and while it seems to convert 383 * `fontSize` to `px`, it doesn't convert `lineHeight`. 384 * 385 * `lineHeight` should always be defined here, this is defensive. 386 */ 387 style.lineHeight = style.lineHeight 388 ? ((style.lineHeight + 'px') as unknown as number) 389 : undefined 390 style.minHeight = webForceMinHeight ? 140 : undefined 391 return style 392 }, [t, fonts, webForceMinHeight]) 393 394 return ( 395 <> 396 <View 397 style={[ 398 styles.container, 399 hasRightPadding && styles.rightPadding, 400 { 401 // @ts-ignore 402 '--mention-color': t.palette.primary_500, 403 }, 404 ]}> 405 {/* @ts-ignore inputStyle is fine */} 406 <EditorContent editor={editor} style={inputStyle} /> 407 </View> 408 409 {isDropping && ( 410 <Portal> 411 <Animated.View 412 style={styles.dropContainer} 413 entering={FadeIn.duration(80)} 414 exiting={FadeOut.duration(80)}> 415 <View 416 style={[ 417 t.atoms.bg, 418 t.atoms.border_contrast_low, 419 styles.dropModal, 420 ]}> 421 <Text 422 style={[ 423 a.text_lg, 424 a.font_semi_bold, 425 t.atoms.text_contrast_medium, 426 t.atoms.border_contrast_high, 427 styles.dropText, 428 ]}> 429 <Trans>Drop to add images</Trans> 430 </Text> 431 </View> 432 </Animated.View> 433 </Portal> 434 )} 435 </> 436 ) 437} 438 439function editorJsonToText( 440 json: JSONContent, 441 isLastDocumentChild: boolean = false, 442): string { 443 let text = '' 444 if (json.type === 'doc') { 445 if (json.content?.length) { 446 for (let i = 0; i < json.content.length; i++) { 447 const node = json.content[i] 448 const isLastNode = i === json.content.length - 1 449 text += editorJsonToText(node, isLastNode) 450 } 451 } 452 } else if (json.type === 'paragraph') { 453 if (json.content?.length) { 454 for (let i = 0; i < json.content.length; i++) { 455 const node = json.content[i] 456 text += editorJsonToText(node) 457 } 458 } 459 if (!isLastDocumentChild) { 460 text += '\n' 461 } 462 } else if (json.type === 'hardBreak') { 463 text += '\n' 464 } else if (json.type === 'text') { 465 text += json.text || '' 466 } else if (json.type === 'mention') { 467 text += `@${json.attrs?.id || ''}` 468 } 469 return text 470} 471 472const styles = StyleSheet.create({ 473 container: { 474 flex: 1, 475 alignSelf: 'flex-start', 476 padding: 5, 477 marginLeft: 8, 478 marginBottom: 10, 479 }, 480 rightPadding: { 481 paddingRight: 32, 482 }, 483 dropContainer: { 484 backgroundColor: '#0007', 485 pointerEvents: 'none', 486 alignItems: 'center', 487 justifyContent: 'center', 488 // @ts-ignore web only -prf 489 position: 'fixed', 490 padding: 16, 491 top: 0, 492 bottom: 0, 493 left: 0, 494 right: 0, 495 }, 496 dropModal: { 497 // @ts-ignore web only 498 boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px', 499 padding: 8, 500 borderWidth: 1, 501 borderRadius: 16, 502 }, 503 dropText: { 504 paddingVertical: 44, 505 paddingHorizontal: 36, 506 borderStyle: 'dashed', 507 borderRadius: 8, 508 borderWidth: 2, 509 }, 510}) 511 512function textToHtml(text: string): string { 513 return text 514 .replace(/&/g, '&amp;') 515 .replace(/</g, '&lt;') 516 .replace(/>/g, '&gt;') 517 .replace(/\n/g, '<br>') 518} 519 520function getImageOrVideoFromUri( 521 items: DataTransferItemList, 522 callback: (uri: string) => void, 523) { 524 for (let index = 0; index < items.length; index++) { 525 const item = items[index] 526 const type = item.type 527 528 if (type === 'text/plain') { 529 item.getAsString(async itemString => { 530 if (isUriImage(itemString)) { 531 const response = await fetch(itemString) 532 const blob = await response.blob() 533 534 if (blob.type.startsWith('image/')) { 535 blobToDataUri(blob).then(callback, err => console.error(err)) 536 } 537 538 if (blob.type.startsWith('video/')) { 539 blobToDataUri(blob).then(callback, err => console.error(err)) 540 } 541 } 542 }) 543 } else if (type.startsWith('image/')) { 544 const file = item.getAsFile() 545 546 if (file) { 547 blobToDataUri(file).then(callback, err => console.error(err)) 548 } 549 } else if (type.startsWith('video/')) { 550 const file = item.getAsFile() 551 552 if (file) { 553 blobToDataUri(file).then(callback, err => console.error(err)) 554 } 555 } 556 } 557}