Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add inline images preview to the Live Markdown Input on the web #50047

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2360,7 +2360,7 @@ PODS:
- RNGoogleSignin (10.0.1):
- GoogleSignIn (~> 7.0)
- React-Core
- RNLiveMarkdown (0.1.143):
- RNLiveMarkdown (0.1.164):
- DoubleConversion
- glog
- hermes-engine
Expand All @@ -2380,9 +2380,9 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNLiveMarkdown/common (= 0.1.143)
- RNLiveMarkdown/newarch (= 0.1.164)
- Yoga
- RNLiveMarkdown/common (0.1.143):
- RNLiveMarkdown/newarch (0.1.164):
- DoubleConversion
- glog
- hermes-engine
Expand Down Expand Up @@ -3229,7 +3229,7 @@ SPEC CHECKSUMS:
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7
RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0
RNLiveMarkdown: e44918843c2638692348f39eafc275698baf0444
RNLiveMarkdown: b2bd97a6f1206be16cf6536c092fe39f986aca34
RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81
rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4
RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
},
"dependencies": {
"@dotlottie/react-player": "^1.6.3",
"@expensify/react-native-live-markdown": "0.1.143",
"@expensify/react-native-live-markdown": "0.1.164",
"@expo/metro-runtime": "~3.2.3",
"@firebase/app": "^0.10.10",
"@firebase/performance": "^0.6.8",
Expand Down
129 changes: 44 additions & 85 deletions src/components/Composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown';
import lodashDebounce from 'lodash/debounce';
import type {BaseSyntheticEvent, ForwardedRef} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {flushSync} from 'react-dom';
// eslint-disable-next-line no-restricted-imports
import type {NativeSyntheticEvent, Text as RNText, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData, ViewStyle} from 'react-native';
import {DeviceEventEmitter, StyleSheet, View} from 'react-native';
import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native';
import {DeviceEventEmitter, StyleSheet} from 'react-native';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
import Text from '@components/Text';
import useHtmlPaste from '@hooks/useHtmlPaste';
import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible';
import useMarkdownStyle from '@hooks/useMarkdownStyle';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
import * as Browser from '@libs/Browser';
import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable';
import * as EmojiUtils from '@libs/EmojiUtils';
Expand All @@ -23,31 +22,9 @@ import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposit
import CONST from '@src/CONST';
import type {ComposerProps} from './types';

/**
* Retrieves the characters from the specified cursor position up to the next space or new line.
*
* @param inputString - The input string.
* @param cursorPosition - The position of the cursor within the input string.
* @returns - The substring from the cursor position up to the next space or new line.
* If no space or new line is found, returns the substring from the cursor position to the end of the input string.
*/
const getNextChars = (inputString: string, cursorPosition: number): string => {
// Get the substring starting from the cursor position
const subString = inputString.substring(cursorPosition);

// Find the index of the next space or new line character
const spaceIndex = subString.search(/[ \n]/);

if (spaceIndex === -1) {
return subString;
}

// If there is a space or new line, return the substring up to the space or new line
return subString.substring(0, spaceIndex);
};

const excludeNoStyles: Array<keyof MarkdownStyle> = [];
const excludeReportMentionStyle: Array<keyof MarkdownStyle> = ['mentionReport'];
const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL];

// Enable Markdown parsing.
// On web we like to have the Text Input field always focused so the user can easily type a new chat
Expand Down Expand Up @@ -83,7 +60,6 @@ function Composer(
const styles = useThemeStyles();
const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles);
const StyleUtils = useStyleUtils();
const textRef = useRef<HTMLElement & RNText>(null);
const textInput = useRef<AnimatedMarkdownTextInputRef | null>(null);
const [selection, setSelection] = useState<
| {
Expand All @@ -97,9 +73,6 @@ function Composer(
start: selectionProp.start,
end: selectionProp.end,
});
const [caretContent, setCaretContent] = useState('');
const [valueBeforeCaret, setValueBeforeCaret] = useState('');
const [textInputWidth, setTextInputWidth] = useState<ViewStyle['width']>('');
const [isRendered, setIsRendered] = useState(false);
const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? '');
const [prevScroll, setPrevScroll] = useState<number | undefined>();
Expand All @@ -118,17 +91,25 @@ function Composer(
*/
const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
const webEvent = event as BaseSyntheticEvent<TextInputSelectionChangeEventData>;
if (shouldCalculateCaretPosition && isRendered) {
// we do flushSync to make sure that the valueBeforeCaret is updated before we calculate the caret position to receive a proper position otherwise we will calculate position for the previous state
flushSync(() => {
setValueBeforeCaret((webEvent.target as HTMLInputElement).value.slice(0, webEvent.nativeEvent.selection.start));
setCaretContent(getNextChars(value ?? '', webEvent.nativeEvent.selection.start));
});
const sel = window.getSelection();
if (shouldCalculateCaretPosition && isRendered && sel) {
const range = sel.getRangeAt(0).cloneRange();
range.collapse(true);
const rect = range.getClientRects()[0] || range.startContainer.parentElement?.getClientRects()[0];
const containerRect = textInput.current?.getBoundingClientRect();

let x = 0;
let y = 0;
if (rect && containerRect) {
x = rect.left - containerRect.left;
y = rect.top - containerRect.top + (textInput?.current?.scrollTop ?? 0) - rect.height / 2;
}

const selectionValue = {
start: webEvent.nativeEvent.selection.start,
end: webEvent.nativeEvent.selection.end,
positionX: (textRef.current?.offsetLeft ?? 0) - CONST.SPACE_CHARACTER_WIDTH,
positionY: textRef.current?.offsetTop,
positionX: x - CONST.SPACE_CHARACTER_WIDTH,
positionY: y,
};

onSelectionChange({
Expand Down Expand Up @@ -335,26 +316,6 @@ function Composer(
[onKeyPress],
);

const renderElementForCaretPosition = (
<View
style={{
position: 'absolute',
zIndex: -1,
opacity: 0,
}}
>
<Text style={[StyleSheet.flatten([style, styles.noSelect]), StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), {maxWidth: textInputWidth}]}>
{`${valueBeforeCaret} `}
<Text
numberOfLines={1}
ref={textRef}
>
{`${caretContent}`}
</Text>
</Text>
</View>
);

const scrollStyleMemo = useMemo(() => {
if (shouldContainScroll) {
return isScrollBarVisible ? [styles.overflowScroll, styles.overscrollBehaviorContain] : styles.overflowHidden;
Expand All @@ -377,32 +338,30 @@ function Composer(
);

return (
<>
<RNMarkdownTextInput
id={CONST.COMPOSER.NATIVE_ID}
autoComplete="off"
autoCorrect={!Browser.isMobileSafari()}
placeholderTextColor={theme.placeholderText}
ref={(el) => (textInput.current = el)}
selection={selection}
style={[inputStyleMemo]}
markdownStyle={markdownStyle}
value={value}
defaultValue={defaultValue}
autoFocus={autoFocus}
inputMode={showSoftInputOnFocus ? 'text' : 'none'}
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
onSelectionChange={addCursorPositionToSelectionChange}
onContentSizeChange={(e) => {
setTextInputWidth(`${e.nativeEvent.contentSize.width}px`);
updateIsFullComposerAvailable({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles);
}}
disabled={isDisabled}
onKeyPress={handleKeyPress}
/>
{shouldCalculateCaretPosition && renderElementForCaretPosition}
</>
<RNMarkdownTextInput
id={CONST.COMPOSER.NATIVE_ID}
autoComplete="off"
autoCorrect={!Browser.isMobileSafari()}
placeholderTextColor={theme.placeholderText}
ref={(el) => (textInput.current = el)}
selection={selection}
style={[inputStyleMemo]}
markdownStyle={markdownStyle}
value={value}
defaultValue={defaultValue}
autoFocus={autoFocus}
inputMode={showSoftInputOnFocus ? 'text' : 'none'}
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
onSelectionChange={addCursorPositionToSelectionChange}
onContentSizeChange={(e) => {
updateIsFullComposerAvailable({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles);
}}
disabled={isDisabled}
onKeyPress={handleKeyPress}
addAuthTokenToImageURLCallback={addEncryptedAuthTokenToURL}
imagePreviewAuthRequiredURLs={imagePreviewAuthRequiredURLs}
/>
);
}

Expand Down
13 changes: 13 additions & 0 deletions src/hooks/useMarkdownStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ function useMarkdownStyle(message: string | null = null, excludeStyles: Array<ke
color: theme.mentionText,
backgroundColor: theme.mentionBG,
},
inlineImage: {
minWidth: variables.inlineImagePreviewMinSize,
minHeight: variables.inlineImagePreviewMinSize,
maxWidth: variables.inlineImagePreviewMaxSize,
maxHeight: variables.inlineImagePreviewMaxSize,
borderRadius: variables.componentBorderRadius,
marginTop: 4,
},
loadingIndicator: {
primaryColor: theme.spinner,
secondaryColor: `${theme.spinner}33`,
},
loadingIndicatorContainer: {},
};

if (excludeStyles.length) {
Expand Down
3 changes: 3 additions & 0 deletions src/styles/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ export default {
composerTooltipShiftVertical: -10,
gbrTooltipShiftHorizontal: -20,

inlineImagePreviewMinSize: 64,
inlineImagePreviewMaxSize: 148,

minimalTopBarOffset: -26,
searchHeaderHeight: 80,
searchListContentMarginTop: 116,
Expand Down
Loading