mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at session/schema 368 lines 10 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 {EditorContent, JSONContent, useEditor} from '@tiptap/react' 15import EventEmitter from 'eventemitter3' 16 17import {usePalette} from '#/lib/hooks/usePalette' 18import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' 19import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 20import {blobToDataUri, isUriImage} from 'lib/media/util' 21import { 22 LinkFacetMatch, 23 suggestLinkCardUri, 24} from 'view/com/composer/text-input/text-input-util' 25import {Portal} from '#/components/Portal' 26import {Text} from '../../util/text/Text' 27import {createSuggestion} from './web/Autocomplete' 28import {Emoji} from './web/EmojiPicker.web' 29import {LinkDecorator} from './web/LinkDecorator' 30import {TagDecorator} from './web/TagDecorator' 31 32export interface TextInputRef { 33 focus: () => void 34 blur: () => void 35 getCursorPosition: () => DOMRect | undefined 36} 37 38interface TextInputProps { 39 richtext: RichText 40 placeholder: string 41 suggestedLinks: Set<string> 42 setRichText: (v: RichText | ((v: RichText) => RichText)) => void 43 onPhotoPasted: (uri: string) => void 44 onPressPublish: (richtext: RichText) => Promise<void> 45 onNewLink: (uri: string) => void 46 onError: (err: string) => void 47} 48 49export const textInputWebEmitter = new EventEmitter() 50 51export const TextInput = React.forwardRef(function TextInputImpl( 52 { 53 richtext, 54 placeholder, 55 setRichText, 56 onPhotoPasted, 57 onPressPublish, 58 onNewLink, 59 }: // onError, TODO 60 TextInputProps, 61 ref, 62) { 63 const autocomplete = useActorAutocompleteFn() 64 const pal = usePalette('default') 65 const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') 66 67 const [isDropping, setIsDropping] = React.useState(false) 68 69 const extensions = React.useMemo( 70 () => [ 71 Document, 72 LinkDecorator, 73 TagDecorator, 74 Mention.configure({ 75 HTMLAttributes: { 76 class: 'mention', 77 }, 78 suggestion: createSuggestion({autocomplete}), 79 }), 80 Paragraph, 81 Placeholder.configure({ 82 placeholder, 83 }), 84 TiptapText, 85 History, 86 Hardbreak, 87 ], 88 [autocomplete, placeholder], 89 ) 90 91 React.useEffect(() => { 92 textInputWebEmitter.addListener('publish', onPressPublish) 93 return () => { 94 textInputWebEmitter.removeListener('publish', onPressPublish) 95 } 96 }, [onPressPublish]) 97 React.useEffect(() => { 98 textInputWebEmitter.addListener('photo-pasted', onPhotoPasted) 99 return () => { 100 textInputWebEmitter.removeListener('photo-pasted', onPhotoPasted) 101 } 102 }, [onPhotoPasted]) 103 104 React.useEffect(() => { 105 const handleDrop = (event: DragEvent) => { 106 const transfer = event.dataTransfer 107 if (transfer) { 108 const items = transfer.items 109 110 getImageFromUri(items, (uri: string) => { 111 textInputWebEmitter.emit('photo-pasted', uri) 112 }) 113 } 114 115 event.preventDefault() 116 setIsDropping(false) 117 } 118 const handleDragEnter = (event: DragEvent) => { 119 const transfer = event.dataTransfer 120 121 event.preventDefault() 122 if (transfer && transfer.types.includes('Files')) { 123 setIsDropping(true) 124 } 125 } 126 const handleDragLeave = (event: DragEvent) => { 127 event.preventDefault() 128 setIsDropping(false) 129 } 130 131 document.body.addEventListener('drop', handleDrop) 132 document.body.addEventListener('dragenter', handleDragEnter) 133 document.body.addEventListener('dragover', handleDragEnter) 134 document.body.addEventListener('dragleave', handleDragLeave) 135 136 return () => { 137 document.body.removeEventListener('drop', handleDrop) 138 document.body.removeEventListener('dragenter', handleDragEnter) 139 document.body.removeEventListener('dragover', handleDragEnter) 140 document.body.removeEventListener('dragleave', handleDragLeave) 141 } 142 }, [setIsDropping]) 143 144 const pastSuggestedUris = useRef(new Set<string>()) 145 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>()) 146 const editor = useEditor( 147 { 148 extensions, 149 editorProps: { 150 attributes: { 151 class: modeClass, 152 }, 153 handlePaste: (_, event) => { 154 const items = event.clipboardData?.items 155 156 if (items === undefined) { 157 return 158 } 159 160 getImageFromUri(items, (uri: string) => { 161 textInputWebEmitter.emit('photo-pasted', uri) 162 }) 163 }, 164 handleKeyDown: (_, event) => { 165 if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') { 166 textInputWebEmitter.emit('publish') 167 return true 168 } 169 }, 170 }, 171 content: generateJSON(richtext.text.toString(), extensions), 172 autofocus: 'end', 173 editable: true, 174 injectCSS: true, 175 onCreate({editor: editorProp}) { 176 // HACK 177 // the 'enter' animation sometimes causes autofocus to fail 178 // (see Composer.web.tsx in shell) 179 // so we wait 200ms (the anim is 150ms) and then focus manually 180 // -prf 181 setTimeout(() => { 182 editorProp.chain().focus('end').run() 183 }, 200) 184 }, 185 onUpdate({editor: editorProp}) { 186 const json = editorProp.getJSON() 187 const newText = editorJsonToText(json) 188 const isPaste = window.event?.type === 'paste' 189 190 const newRt = new RichText({text: newText}) 191 newRt.detectFacetsWithoutResolution() 192 setRichText(newRt) 193 194 const nextDetectedUris = new Map<string, LinkFacetMatch>() 195 if (newRt.facets) { 196 for (const facet of newRt.facets) { 197 for (const feature of facet.features) { 198 if (AppBskyRichtextFacet.isLink(feature)) { 199 nextDetectedUris.set(feature.uri, {facet, rt: newRt}) 200 } 201 } 202 } 203 } 204 205 const suggestedUri = suggestLinkCardUri( 206 isPaste, 207 nextDetectedUris, 208 prevDetectedUris.current, 209 pastSuggestedUris.current, 210 ) 211 prevDetectedUris.current = nextDetectedUris 212 if (suggestedUri) { 213 onNewLink(suggestedUri) 214 } 215 }, 216 }, 217 [modeClass], 218 ) 219 220 const onEmojiInserted = React.useCallback( 221 (emoji: Emoji) => { 222 editor?.chain().focus().insertContent(emoji.native).run() 223 }, 224 [editor], 225 ) 226 React.useEffect(() => { 227 textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) 228 return () => { 229 textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) 230 } 231 }, [onEmojiInserted]) 232 233 React.useImperativeHandle(ref, () => ({ 234 focus: () => {}, // TODO 235 blur: () => {}, // TODO 236 getCursorPosition: () => { 237 const pos = editor?.state.selection.$anchor.pos 238 return pos ? editor?.view.coordsAtPos(pos) : undefined 239 }, 240 })) 241 242 return ( 243 <> 244 <View style={styles.container}> 245 <EditorContent 246 editor={editor} 247 style={{color: pal.text.color as string}} 248 /> 249 </View> 250 251 {isDropping && ( 252 <Portal> 253 <Animated.View 254 style={styles.dropContainer} 255 entering={FadeIn.duration(80)} 256 exiting={FadeOut.duration(80)}> 257 <View style={[pal.view, pal.border, styles.dropModal]}> 258 <Text 259 type="lg" 260 style={[pal.text, pal.borderDark, styles.dropText]}> 261 <Trans>Drop to add images</Trans> 262 </Text> 263 </View> 264 </Animated.View> 265 </Portal> 266 )} 267 </> 268 ) 269}) 270 271function editorJsonToText( 272 json: JSONContent, 273 isLastDocumentChild: boolean = false, 274): string { 275 let text = '' 276 if (json.type === 'doc') { 277 if (json.content?.length) { 278 for (let i = 0; i < json.content.length; i++) { 279 const node = json.content[i] 280 const isLastNode = i === json.content.length - 1 281 text += editorJsonToText(node, isLastNode) 282 } 283 } 284 } else if (json.type === 'paragraph') { 285 if (json.content?.length) { 286 for (let i = 0; i < json.content.length; i++) { 287 const node = json.content[i] 288 text += editorJsonToText(node) 289 } 290 } 291 if (!isLastDocumentChild) { 292 text += '\n' 293 } 294 } else if (json.type === 'hardBreak') { 295 text += '\n' 296 } else if (json.type === 'text') { 297 text += json.text || '' 298 } else if (json.type === 'mention') { 299 text += `@${json.attrs?.id || ''}` 300 } 301 return text 302} 303 304const styles = StyleSheet.create({ 305 container: { 306 flex: 1, 307 alignSelf: 'flex-start', 308 padding: 5, 309 marginLeft: 8, 310 marginBottom: 10, 311 }, 312 dropContainer: { 313 backgroundColor: '#0007', 314 pointerEvents: 'none', 315 alignItems: 'center', 316 justifyContent: 'center', 317 // @ts-ignore web only -prf 318 position: 'fixed', 319 padding: 16, 320 top: 0, 321 bottom: 0, 322 left: 0, 323 right: 0, 324 }, 325 dropModal: { 326 // @ts-ignore web only 327 boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px', 328 padding: 8, 329 borderWidth: 1, 330 borderRadius: 16, 331 }, 332 dropText: { 333 paddingVertical: 44, 334 paddingHorizontal: 36, 335 borderStyle: 'dashed', 336 borderRadius: 8, 337 borderWidth: 2, 338 }, 339}) 340 341function getImageFromUri( 342 items: DataTransferItemList, 343 callback: (uri: string) => void, 344) { 345 for (let index = 0; index < items.length; index++) { 346 const item = items[index] 347 const type = item.type 348 349 if (type === 'text/plain') { 350 item.getAsString(async itemString => { 351 if (isUriImage(itemString)) { 352 const response = await fetch(itemString) 353 const blob = await response.blob() 354 355 if (blob.type.startsWith('image/')) { 356 blobToDataUri(blob).then(callback, err => console.error(err)) 357 } 358 } 359 }) 360 } else if (type.startsWith('image/')) { 361 const file = item.getAsFile() 362 363 if (file) { 364 blobToDataUri(file).then(callback, err => console.error(err)) 365 } 366 } 367 } 368}