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