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 {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, '&')
515 .replace(/</g, '<')
516 .replace(/>/g, '>')
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}