Skip to content

Commit

Permalink
Merge pull request Expensify#31279 from barros001/paste-emoji-issue2
Browse files Browse the repository at this point in the history
Correctly position cursor after pasting text containing emoji
  • Loading branch information
stitesExpensify authored Nov 21, 2023
2 parents f4c94a3 + d3c1655 commit 360fe05
Show file tree
Hide file tree
Showing 5 changed files with 70 additions and 37 deletions.
17 changes: 1 addition & 16 deletions src/libs/ComposerUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,4 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo
return (isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen()) || isKeyboardShown;
}

/**
* Returns the length of the common suffix between two input strings.
* The common suffix is the number of characters shared by both strings
* at the end (suffix) until a mismatch is encountered.
*
* @returns The length of the common suffix between the strings.
*/
function getCommonSuffixLength(str1: string, str2: string): number {
let i = 0;
while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) {
i++;
}
return i;
}

export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys, getCommonSuffixLength};
export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys};
38 changes: 26 additions & 12 deletions src/libs/EmojiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import emojisTrie from './EmojiTrie';
type HeaderIndice = {code: string; index: number; icon: React.FC<SvgProps>};
type EmojiSpacer = {code: string; spacer: boolean};
type EmojiPickerList = Array<EmojiSpacer | Emoji | HeaderEmoji>;
type ReplacedEmoji = {text: string; emojis: Emoji[]};
type ReplacedEmoji = {text: string; emojis: Emoji[]; cursorPosition?: number};
type UserReactions = {
id: string;
skinTones: Record<number, string>;
Expand Down Expand Up @@ -333,8 +333,11 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI
if (!emojiData || emojiData.length === 0) {
return {text: newText, emojis};
}
for (let i = 0; i < emojiData.length; i++) {
const name = emojiData[i].slice(1, -1);

let cursorPosition;

for (const emoji of emojiData) {
const name = emoji.slice(1, -1);
let checkEmoji = trie.search(name);
// If the user has selected a language other than English, and the emoji doesn't exist in that language,
// we will check if the emoji exists in English.
Expand All @@ -346,35 +349,46 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI
}
}
if (checkEmoji?.metaData?.code && checkEmoji?.metaData?.name) {
let emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone);
const emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone);
emojis.push({
name,
code: checkEmoji.metaData?.code,
types: checkEmoji.metaData.types,
});

// If this is the last emoji in the message and it's the end of the message so far,
// add a space after it so the user can keep typing easily.
if (i === emojiData.length - 1) {
emojiReplacement += ' ';
}
// Set the cursor to the end of the last replaced Emoji. Note that we position after
// the extra space, if we added one.
cursorPosition = newText.indexOf(emoji) + emojiReplacement.length;

newText = newText.replace(emoji, emojiReplacement);
}
}

// cursorPosition, when not undefined, points to the end of the last emoji that was replaced.
// In that case we want to append a space at the cursor position, but only if the next character
// is not already a space (to avoid double spaces).
if (cursorPosition && cursorPosition > 0) {
const space = ' ';

newText = newText.replace(emojiData[i], emojiReplacement);
if (newText.charAt(cursorPosition) !== space) {
newText = newText.slice(0, cursorPosition) + space + newText.slice(cursorPosition);
}
cursorPosition += space.length;
}

return {text: newText, emojis};
return {text: newText, emojis, cursorPosition};
}

/**
* Find all emojis in a text and replace them with their code.
*/
function replaceAndExtractEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT): ReplacedEmoji {
const {text: convertedText = '', emojis = []} = replaceEmojis(text, preferredSkinTone, lang);
const {text: convertedText = '', emojis = [], cursorPosition} = replaceEmojis(text, preferredSkinTone, lang);

return {
text: convertedText,
emojis: emojis.concat(extractEmojis(text)),
cursorPosition,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ function ComposerWithSuggestions({
const updateComment = useCallback(
(commentValue, shouldDebounceSaveComment) => {
raiseIsScrollLikelyLayoutTriggered();
const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale);
const {text: newComment, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale);
if (!_.isEmpty(emojis)) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
if (!_.isEmpty(newEmojis)) {
Expand All @@ -234,10 +234,10 @@ function ComposerWithSuggestions({
setIsCommentEmpty(!!newCommentConverted.match(/^(\s)*$/));
setValue(newCommentConverted);
if (commentValue !== newComment) {
const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment);
const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition || 0);
setSelection({
start: newComment.length - remainder,
end: newComment.length - remainder,
start: position,
end: position,
});
}

Expand Down Expand Up @@ -270,6 +270,7 @@ function ComposerWithSuggestions({
suggestionsRef,
raiseIsScrollLikelyLayoutTriggered,
debouncedSaveReportComment,
selection.end,
],
);

Expand Down
13 changes: 8 additions & 5 deletions src/pages/home/report/ReportActionItemMessageEdit.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ function ReportActionItemMessageEdit(props) {
const textInputRef = useRef(null);
const isFocusedRef = useRef(false);
const insertedEmojis = useRef([]);
const draftRef = useRef(draft);

useEffect(() => {
if (ReportActionsUtils.isDeletedAction(props.action) || props.draftMessage === props.action.message[0].html) {
Expand Down Expand Up @@ -241,7 +242,7 @@ function ReportActionItemMessageEdit(props) {
*/
const updateDraft = useCallback(
(newDraftInput) => {
const {text: newDraft, emojis} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale);
const {text: newDraft, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale);

if (!_.isEmpty(emojis)) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
Expand All @@ -255,13 +256,15 @@ function ReportActionItemMessageEdit(props) {
setDraft(newDraft);

if (newDraftInput !== newDraft) {
const remainder = ComposerUtils.getCommonSuffixLength(newDraftInput, newDraft);
const position = Math.max(selection.end + (newDraft.length - draftRef.current.length), cursorPosition || 0);
setSelection({
start: newDraft.length - remainder,
end: newDraft.length - remainder,
start: position,
end: position,
});
}

draftRef.current = newDraft;

// This component is rendered only when draft is set to a non-empty string. In order to prevent component
// unmount when user deletes content of textarea, we set previous message instead of empty string.
if (newDraft.trim().length > 0) {
Expand All @@ -271,7 +274,7 @@ function ReportActionItemMessageEdit(props) {
debouncedSaveDraft(props.action.message[0].html);
}
},
[props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale],
[props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale, selection.end],
);

useEffect(() => {
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/EmojiTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,41 @@ describe('EmojiTest', () => {
expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄 ');
});

it('will add a space after the last emoji', () => {
const text = 'Hi :smile::wave:';
expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 ');
});

it('will add a space after the last emoji if there is text after it', () => {
const text = 'Hi :smile::wave:space after last emoji';
expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space after last emoji');
});

it('will add a space after the last emoji if there is invalid emoji after it', () => {
const text = 'Hi :smile::wave:space when :invalidemoji: present';
expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space when :invalidemoji: present');
});

it('will not add a space after the last emoji if there if last emoji is immediately followed by a space', () => {
const text = 'Hi :smile::wave: space after last emoji';
expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space after last emoji');
});

it('will return correct cursor position', () => {
const text = 'Hi :smile: there :wave:!';
expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(15);
});

it('will return correct cursor position when space is not added by space follows last emoji', () => {
const text = 'Hi :smile: there!';
expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(6);
});

it('will return undefined cursor position when no emoji is replaced', () => {
const text = 'Hi there!';
expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(undefined);
});

it('suggests emojis when typing emojis prefix after colon', () => {
const text = 'Hi :coffin';
expect(EmojiUtils.suggestEmojis(text, 'en')).toEqual([{code: '⚰️', name: 'coffin'}]);
Expand Down

0 comments on commit 360fe05

Please sign in to comment.