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