diff options
Diffstat (limited to 'src/view/com/composer/text-input')
4 files changed, 204 insertions, 73 deletions
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 7e39f6aed..3d0d5ab8d 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -32,6 +32,7 @@ import {POST_IMG_MAX} from 'lib/constants' export interface TextInputRef { focus: () => void blur: () => void + getCursorPosition: () => DOMRect | undefined } interface TextInputProps extends ComponentProps<typeof RNTextInput> { @@ -74,6 +75,7 @@ export const TextInput = forwardRef(function TextInputImpl( blur: () => { textInput.current?.blur() }, + getCursorPosition: () => undefined, // Not implemented on native })) const onChangeText = useCallback( @@ -215,6 +217,7 @@ export const TextInput = forwardRef(function TextInputImpl( autoFocus={true} allowFontScaling multiline + scrollEnabled={false} numberOfLines={4} style={[ pal.text, diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 206a3205b..f2012a630 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -9,7 +9,7 @@ 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} from '@tiptap/extension-text' +import {Text as TiptapText} from '@tiptap/extension-text' import isEqual from 'lodash.isequal' import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' @@ -18,10 +18,16 @@ 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 { @@ -52,7 +58,11 @@ export const TextInput = React.forwardRef(function TextInputImpl( ) { 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, @@ -67,7 +77,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( Placeholder.configure({ placeholder, }), - Text, + TiptapText, History, Hardbreak, ], @@ -87,6 +97,46 @@ export const TextInput = React.forwardRef(function TextInputImpl( } }, [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) + } + + document.body.addEventListener('drop', handleDrop) + document.body.addEventListener('dragenter', handleDragEnter) + document.body.addEventListener('dragover', handleDragEnter) + document.body.addEventListener('dragleave', handleDragLeave) + + return () => { + document.body.removeEventListener('drop', handleDrop) + document.body.removeEventListener('dragenter', handleDragEnter) + document.body.removeEventListener('dragover', handleDragEnter) + document.body.removeEventListener('dragleave', handleDragLeave) + } + }, [setIsDropping]) + const editor = useEditor( { extensions, @@ -169,12 +219,35 @@ export const TextInput = React.forwardRef(function TextInputImpl( 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> + <> + <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> + )} + </> ) }) @@ -205,6 +278,33 @@ const styles = StyleSheet.create({ marginLeft: 8, marginBottom: 10, }, + dropContainer: { + backgroundColor: '#0007', + pointerEvents: 'none', + alignItems: 'center', + justifyContent: 'center', + // @ts-ignore web only -prf + position: 'fixed', + 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( @@ -213,25 +313,25 @@ function getImageFromUri( ) { for (let index = 0; index < items.length; index++) { const item = items[index] - const {kind, type} = item + const type = item.type if (type === 'text/plain') { + console.log('hit') item.getAsString(async itemString => { if (isUriImage(itemString)) { const response = await fetch(itemString) const blob = await response.blob() - blobToDataUri(blob).then(callback, err => console.error(err)) + + if (blob.type.startsWith('image/')) { + blobToDataUri(blob).then(callback, err => console.error(err)) + } } }) - } - - if (kind === 'file') { + } else if (type.startsWith('image/')) { const file = item.getAsFile() - if (file instanceof Blob) { - blobToDataUri(new Blob([file], {type: item.type})).then(callback, err => - console.error(err), - ) + if (file) { + blobToDataUri(file).then(callback, err => console.error(err)) } } } diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index 51197b8e4..76058fed3 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -17,6 +17,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' import {useGrapheme} from '../hooks/useGrapheme' +import {Trans} from '@lingui/macro' interface MentionListRef { onKeyDown: (props: SuggestionKeyDownProps) => boolean @@ -187,7 +188,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( }) ) : ( <Text type="sm" style={[pal.text, styles.noResult]}> - No result + <Trans>No result</Trans> </Text> )} </View> diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx index f4b2d99b0..149362116 100644 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx @@ -1,11 +1,17 @@ import React from 'react' import Picker from '@emoji-mart/react' -import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { + StyleSheet, + TouchableWithoutFeedback, + useWindowDimensions, + View, +} from 'react-native' import {textInputWebEmitter} from '../TextInput.web' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' -import {useMediaQuery} from 'react-responsive' + +const HEIGHT_OFFSET = 40 +const WIDTH_OFFSET = 100 +const PICKER_HEIGHT = 435 + HEIGHT_OFFSET +const PICKER_WIDTH = 350 + WIDTH_OFFSET export type Emoji = { aliases?: string[] @@ -18,59 +24,87 @@ export type Emoji = { unified: string } -export function EmojiPickerButton() { - const pal = usePalette('default') - const [open, setOpen] = React.useState(false) - const onOpenChange = (o: boolean) => { - setOpen(o) - } - const close = () => { - setOpen(false) - } +export interface EmojiPickerState { + isOpen: boolean + pos: {top: number; left: number; right: number; bottom: number} +} - return ( - <DropdownMenu.Root open={open} onOpenChange={onOpenChange}> - <DropdownMenu.Trigger style={styles.trigger}> - <FontAwesomeIcon - icon={['far', 'face-smile']} - color={pal.colors.link} - size={22} - /> - </DropdownMenu.Trigger> - - <DropdownMenu.Portal> - <EmojiPicker close={close} /> - </DropdownMenu.Portal> - </DropdownMenu.Root> - ) +interface IProps { + state: EmojiPickerState + close: () => void } -export function EmojiPicker({close}: {close: () => void}) { +export function EmojiPicker({state, close}: IProps) { + const {height, width} = useWindowDimensions() + + const isShiftDown = React.useRef(false) + + const position = React.useMemo(() => { + const fitsBelow = state.pos.top + PICKER_HEIGHT < height + const fitsAbove = PICKER_HEIGHT < state.pos.top + const placeOnLeft = PICKER_WIDTH < state.pos.left + const screenYMiddle = height / 2 - PICKER_HEIGHT / 2 + + if (fitsBelow) { + return { + top: state.pos.top + HEIGHT_OFFSET, + } + } else if (fitsAbove) { + return { + bottom: height - state.pos.bottom + HEIGHT_OFFSET, + } + } else { + return { + top: screenYMiddle, + left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined, + right: !placeOnLeft + ? width - state.pos.right - PICKER_WIDTH + : undefined, + } + } + }, [state.pos, height, width]) + + React.useEffect(() => { + if (!state.isOpen) return + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + isShiftDown.current = true + } + } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + isShiftDown.current = false + } + } + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('keyup', onKeyUp, true) + + return () => { + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('keyup', onKeyUp, true) + } + }, [state.isOpen]) + const onInsert = (emoji: Emoji) => { textInputWebEmitter.emit('emoji-inserted', emoji) - close() + + if (!isShiftDown.current) { + close() + } } - const reducedPadding = useMediaQuery({query: '(max-height: 750px)'}) - const noPadding = useMediaQuery({query: '(max-height: 550px)'}) - const noPicker = useMediaQuery({query: '(max-height: 350px)'}) + + if (!state.isOpen) return null return ( - // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors - <TouchableWithoutFeedback onPress={close} accessibilityViewIsModal> + <TouchableWithoutFeedback + accessibilityRole="button" + onPress={close} + accessibilityViewIsModal> <View style={styles.mask}> {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} - <TouchableWithoutFeedback - onPress={e => { - e.stopPropagation() // prevent event from bubbling up to the mask - }}> - <View - style={[ - styles.picker, - { - paddingTop: noPadding ? 0 : reducedPadding ? 150 : 325, - display: noPicker ? 'none' : 'flex', - }, - ]}> + <TouchableWithoutFeedback onPress={e => e.stopPropagation()}> + <View style={[{position: 'absolute'}, position]}> <Picker data={async () => { return (await import('./EmojiPickerData.json')).default @@ -87,21 +121,14 @@ export function EmojiPicker({close}: {close: () => void}) { const styles = StyleSheet.create({ mask: { - position: 'absolute', + // @ts-ignore web ony + position: 'fixed', top: 0, left: 0, right: 0, width: '100%', height: '100%', - }, - trigger: { - backgroundColor: 'transparent', - // @ts-ignore web only -prf - border: 'none', - paddingTop: 4, - paddingLeft: 12, - paddingRight: 12, - cursor: 'pointer', + alignItems: 'center', }, picker: { marginHorizontal: 'auto', |