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/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, '&')
453 .replace(/</g, '<')
454 .replace(/>/g, '>')
455 .replace(/"/g, '"')
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}