Files
bsky-social-app/src/view/com/composer/text-input/TextInput.web.tsx
T
2024-01-21 08:43:11 +07:00

340 lines
9.3 KiB
TypeScript

import React from 'react'
import {StyleSheet, View} from 'react-native'
import {RichText, AppBskyRichtextFacet} from '@atproto/api'
import EventEmitter from 'eventemitter3'
import {useEditor, EditorContent, JSONContent} from '@tiptap/react'
import {Document} from '@tiptap/extension-document'
import History from '@tiptap/extension-history'
import Hardbreak from '@tiptap/extension-hard-break'
import {Mention} from '@tiptap/extension-mention'
import {Paragraph} from '@tiptap/extension-paragraph'
import {Placeholder} from '@tiptap/extension-placeholder'
import {Text as TiptapText} from '@tiptap/extension-text'
import isEqual from 'lodash.isequal'
import {createSuggestion} from './web/Autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {isUriImage, blobToDataUri} from 'lib/media/util'
import {Emoji} from './web/EmojiPicker.web'
import {LinkDecorator} from './web/LinkDecorator'
import {generateJSON} from '@tiptap/html'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {usePalette} from '#/lib/hooks/usePalette'
import {Portal} from '#/components/Portal'
import {Text} from '../../util/text/Text'
import {Trans} from '@lingui/macro'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
export interface TextInputRef {
focus: () => void
blur: () => void
getCursorPosition: () => DOMRect | undefined
}
interface TextInputProps {
richtext: RichText
placeholder: string
suggestedLinks: Set<string>
setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => Promise<void>
onSuggestedLinksChanged: (uris: Set<string>) => void
onError: (err: string) => void
}
export const textInputWebEmitter = new EventEmitter()
export const TextInput = React.forwardRef(function TextInputImpl(
{
richtext,
placeholder,
suggestedLinks,
setRichText,
onPhotoPasted,
onPressPublish,
onSuggestedLinksChanged,
}: // onError, TODO
TextInputProps,
ref,
) {
const autocomplete = useActorAutocompleteFn()
const pal = usePalette('default')
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
const [isDropping, setIsDropping] = React.useState(false)
const extensions = React.useMemo(
() => [
Document,
LinkDecorator,
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: createSuggestion({autocomplete}),
}),
Paragraph,
Placeholder.configure({
placeholder,
}),
TiptapText,
History,
Hardbreak,
],
[autocomplete, placeholder],
)
React.useEffect(() => {
textInputWebEmitter.addListener('publish', onPressPublish)
return () => {
textInputWebEmitter.removeListener('publish', onPressPublish)
}
}, [onPressPublish])
React.useEffect(() => {
textInputWebEmitter.addListener('photo-pasted', onPhotoPasted)
return () => {
textInputWebEmitter.removeListener('photo-pasted', onPhotoPasted)
}
}, [onPhotoPasted])
React.useEffect(() => {
const handleDrop = (event: DragEvent) => {
const transfer = event.dataTransfer
if (transfer) {
const items = transfer.items
getImageFromUri(items, (uri: string) => {
textInputWebEmitter.emit('photo-pasted', uri)
})
}
event.preventDefault()
setIsDropping(false)
}
const handleDragEnter = (event: DragEvent) => {
const transfer = event.dataTransfer
event.preventDefault()
if (transfer && transfer.types.includes('Files')) {
setIsDropping(true)
}
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
setIsDropping(false)
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
document.body.addEventListener('drop', handleDrop)
document.body.addEventListener('dragenter', handleDragEnter)
document.body.addEventListener('dragleave', handleDragLeave)
document.body.addEventListener('dragover', handleDragOver)
return () => {
document.body.removeEventListener('drop', handleDrop)
document.body.removeEventListener('dragenter', handleDragEnter)
document.body.removeEventListener('dragleave', handleDragLeave)
document.body.removeEventListener('dragover', handleDragOver)
}
}, [setIsDropping])
const editor = useEditor(
{
extensions,
editorProps: {
attributes: {
class: modeClass,
},
handlePaste: (_, event) => {
const items = event.clipboardData?.items
if (items === undefined) {
return
}
getImageFromUri(items, (uri: string) => {
textInputWebEmitter.emit('photo-pasted', uri)
})
},
handleKeyDown: (_, event) => {
if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') {
textInputWebEmitter.emit('publish')
return true
}
},
},
content: generateJSON(richtext.text.toString(), extensions),
autofocus: 'end',
editable: true,
injectCSS: true,
onCreate({editor: editorProp}) {
// HACK
// the 'enter' animation sometimes causes autofocus to fail
// (see Composer.web.tsx in shell)
// so we wait 200ms (the anim is 150ms) and then focus manually
// -prf
setTimeout(() => {
editorProp.chain().focus('end').run()
}, 200)
},
onUpdate({editor: editorProp}) {
const json = editorProp.getJSON()
const newRt = new RichText({text: editorJsonToText(json).trimEnd()})
newRt.detectFacetsWithoutResolution()
setRichText(newRt)
const set: Set<string> = new Set()
if (newRt.facets) {
for (const facet of newRt.facets) {
for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) {
set.add(feature.uri)
}
}
}
}
if (!isEqual(set, suggestedLinks)) {
onSuggestedLinksChanged(set)
}
},
},
[modeClass],
)
const onEmojiInserted = React.useCallback(
(emoji: Emoji) => {
editor?.chain().focus().insertContent(emoji.native).run()
},
[editor],
)
React.useEffect(() => {
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
return () => {
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
}
}, [onEmojiInserted])
React.useImperativeHandle(ref, () => ({
focus: () => {}, // TODO
blur: () => {}, // TODO
getCursorPosition: () => {
const pos = editor?.state.selection.$anchor.pos
return pos ? editor?.view.coordsAtPos(pos) : undefined
},
}))
return (
<>
<View style={styles.container}>
<EditorContent editor={editor} />
</View>
{isDropping && (
<Portal>
<Animated.View
style={styles.dropContainer}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}>
<View style={[pal.view, pal.border, styles.dropModal]}>
<Text
type="lg"
style={[pal.text, pal.borderDark, styles.dropText]}>
<Trans>Drop to add images</Trans>
</Text>
</View>
</Animated.View>
</Portal>
)}
</>
)
})
function editorJsonToText(json: JSONContent): string {
let text = ''
if (json.type === 'doc' || json.type === 'paragraph') {
if (json.content?.length) {
for (const node of json.content) {
text += editorJsonToText(node)
}
}
text += '\n'
} else if (json.type === 'hardBreak') {
text += '\n'
} else if (json.type === 'text') {
text += json.text || ''
} else if (json.type === 'mention') {
text += `@${json.attrs?.id || ''}`
}
return text
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignSelf: 'flex-start',
padding: 5,
marginLeft: 8,
marginBottom: 10,
},
dropContainer: {
backgroundColor: '#0007',
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
padding: 16,
top: 0,
bottom: 0,
left: 0,
right: 0,
},
dropModal: {
// @ts-ignore web only
boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
padding: 8,
borderWidth: 1,
borderRadius: 16,
},
dropText: {
paddingVertical: 44,
paddingHorizontal: 36,
borderStyle: 'dashed',
borderRadius: 8,
borderWidth: 2,
},
})
function getImageFromUri(
items: DataTransferItemList,
callback: (uri: string) => void,
) {
for (let index = 0; index < items.length; index++) {
const item = items[index]
const type = item.type
if (type === 'text/plain') {
item.getAsString(async itemString => {
if (isUriImage(itemString)) {
const response = await fetch(itemString)
const blob = await response.blob()
if (blob.type.startsWith('image/')) {
blobToDataUri(blob).then(callback, err => console.error(err))
}
}
})
} else if (type.startsWith('image/')) {
const file = item.getAsFile()
if (file) {
blobToDataUri(file).then(callback, err => console.error(err))
}
}
}
}