Files
element-web/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts
David Langley 5d16a38b17 Rich text Editor: Auto-replace plain text emoticons with emoji (#12828)
* Detect autoReplaceEmoji setting

* Add plain text emoticon to emoji replacement for plain and rich text modes of the RTE.

* Use latest wysiwyg

* lint

* fix existing jest tests and docs

* Add unit tests

* Update wysiwyg to fix flakes.

* fix wording of tests and comments

* use useSettingValue
2024-08-07 08:39:55 +00:00

418 lines
17 KiB
TypeScript

/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings";
import { AllowedMentionAttributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
import { SyntheticEvent, useState, SetStateAction } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { isNotNull } from "../../../../../Typeguards";
/**
* Information about the current state of the `useSuggestion` hook.
*/
export type Suggestion = {
mappedSuggestion: MappedSuggestion;
/* The information in a `MappedSuggestion` is sufficient to generate a query for the autocomplete
component but more information is required to allow manipulation of the correct part of the DOM
when selecting an option from the autocomplete. These three pieces of information allow us to
do that.
*/
node: Node;
startOffset: number;
endOffset: number;
};
type SuggestionState = Suggestion | null;
/**
* React hook to allow tracking and replacing of mentions and commands in a div element
*
* @param editorRef - a ref to the div that is the composer textbox
* @param setText - setter function to set the content of the composer
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
* @returns
* - `handleMention`: a function that will insert @ or # mentions which are selected from
* the autocomplete into the composer, given an href, the text to display, and any additional attributes
* - `handleCommand`: a function that will replace the content of the composer with the given replacement text.
* Can be used to process autocomplete of slash commands
* - `onSelect`: a selection change listener to be attached to the plain text composer
* - `suggestion`: if the cursor is inside something that could be interpreted as a command or a mention,
* this will be an object representing that command or mention, otherwise it is null
*/
export function useSuggestion(
editorRef: React.RefObject<HTMLDivElement>,
setText: (text?: string) => void,
isAutoReplaceEmojiEnabled?: boolean,
): {
handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void;
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
handleCommand: (text: string) => void;
handleEmojiReplacement: () => void;
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null;
} {
const [suggestionData, setSuggestionData0] = useState<SuggestionState>(null);
// debug for https://github.com/vector-im/element-web/issues/26037
const setSuggestionData = (suggestionData: SetStateAction<SuggestionState>): void => {
// setState allows either the data itself or a callback which returns the data
logger.log(
`## 26037 ## wysiwyg useSuggestion hook setting suggestion data to ${
suggestionData === null || suggestionData instanceof Function
? suggestionData
: suggestionData.mappedSuggestion.keyChar + suggestionData.mappedSuggestion.text
}`,
);
setSuggestionData0(suggestionData);
};
// We create a `selectionchange` handler here because we need to know when the user has moved the cursor,
// we can not depend on input events only
const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData, isAutoReplaceEmojiEnabled);
const handleMention = (href: string, displayName: string, attributes: AllowedMentionAttributes): void =>
processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText);
const handleAtRoomMention = (attributes: AllowedMentionAttributes): void =>
processMention("#", "@room", attributes, suggestionData, setSuggestionData, setText);
const handleCommand = (replacementText: string): void =>
processCommand(replacementText, suggestionData, setSuggestionData, setText);
const handleEmojiReplacement = (): void => processEmojiReplacement(suggestionData, setSuggestionData, setText);
return {
suggestion: suggestionData?.mappedSuggestion ?? null,
handleCommand,
handleMention,
handleAtRoomMention,
handleEmojiReplacement,
onSelect,
};
}
/**
* When the selection changes inside the current editor, check to see if the cursor is inside
* something that could be a command or a mention and update the suggestion state if so
*
* @param editorRef - ref to the composer
* @param setSuggestionData - the setter for the suggestion state
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
*/
export function processSelectionChange(
editorRef: React.RefObject<HTMLDivElement>,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
isAutoReplaceEmojiEnabled?: boolean,
): void {
const selection = document.getSelection();
// return early if we do not have a current editor ref with a cursor selection inside a text node
if (
editorRef.current === null ||
selection === null ||
!selection.isCollapsed ||
selection.anchorNode?.nodeName !== "#text"
) {
setSuggestionData(null);
return;
}
// from here onwards we have a cursor inside a text node
const { anchorNode: currentNode, anchorOffset: currentOffset } = selection;
// if we have no text content, return, clearing the suggestion state
if (currentNode.textContent === null) {
setSuggestionData(null);
return;
}
const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();
const isFirstTextNode = currentNode === firstTextNode;
const foundSuggestion = findSuggestionInText(
currentNode.textContent,
currentOffset,
isFirstTextNode,
isAutoReplaceEmojiEnabled,
);
// if we have not found a suggestion, return, clearing the suggestion state
if (foundSuggestion === null) {
setSuggestionData(null);
return;
}
setSuggestionData({
mappedSuggestion: foundSuggestion.mappedSuggestion,
node: currentNode,
startOffset: foundSuggestion.startOffset,
endOffset: foundSuggestion.endOffset,
});
}
/**
* Replaces the relevant part of the editor text with a link representing a mention after it
* is selected from the autocomplete.
*
* @param href - the href that the inserted link will use
* @param displayName - the text content of the link
* @param attributes - additional attributes to add to the link, can include data-* attributes
* @param suggestionData - representation of the part of the DOM that will be replaced
* @param setSuggestionData - setter function to set the suggestion state
* @param setText - setter function to set the content of the composer
*/
export function processMention(
href: string,
displayName: string,
attributes: AllowedMentionAttributes, // these will be used when formatting the link as a pill
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text?: string) => void,
): void {
// if we do not have a suggestion, return early
if (suggestionData === null) {
return;
}
const { node } = suggestionData;
// create an <a> element with the required attributes to allow us to interpret the mention as being a pill
const linkElement = document.createElement("a");
const linkTextNode = document.createTextNode(displayName);
linkElement.setAttribute("href", href);
linkElement.setAttribute("contenteditable", "false");
for (const [attr, value] of attributes.entries()) {
linkElement.setAttribute(attr, value);
}
linkElement.appendChild(linkTextNode);
// create text nodes to go before and after the link
const leadingTextNode = document.createTextNode(node.textContent?.slice(0, suggestionData.startOffset) || "\u200b");
const trailingTextNode = document.createTextNode(` ${node.textContent?.slice(suggestionData.endOffset) ?? ""}`);
// now add the leading text node, link element and trailing text node before removing the node we are replacing
const parentNode = node.parentNode;
if (isNotNull(parentNode)) {
parentNode.insertBefore(leadingTextNode, node);
parentNode.insertBefore(linkElement, node);
parentNode.insertBefore(trailingTextNode, node);
parentNode.removeChild(node);
}
// move the selection to the trailing text node
document.getSelection()?.setBaseAndExtent(trailingTextNode, 1, trailingTextNode, 1);
// set the text content to be the innerHTML of the current editor ref and clear the suggestion state
setText();
setSuggestionData(null);
}
/**
* Replaces the relevant part of the editor text with the replacement text after a command is selected
* from the autocomplete.
*
* @param replacementText - the text that we will insert into the DOM
* @param suggestionData - representation of the part of the DOM that will be replaced
* @param setSuggestionData - setter function to set the suggestion state
* @param setText - setter function to set the content of the composer
*/
export function processCommand(
replacementText: string,
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text?: string) => void,
): void {
// if we do not have a suggestion, return early
if (suggestionData === null) {
return;
}
const { node } = suggestionData;
// for a command, we know we start at the beginning of the text node, so build the replacement
// string (note trailing space) and manually adjust the node's textcontent
const newContent = `${replacementText} `;
node.textContent = newContent;
// then set the cursor to the end of the node, update the `content` state in the usePlainTextListeners
// hook and clear the suggestion from state
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
setText(newContent);
setSuggestionData(null);
}
/**
* Replaces the relevant part of the editor text, replacing the plain text emoitcon with the suggested emoji.
*
* @param suggestionData - representation of the part of the DOM that will be replaced
* @param setSuggestionData - setter function to set the suggestion state
* @param setText - setter function to set the content of the composer
*/
export function processEmojiReplacement(
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text?: string) => void,
): void {
// if we do not have a suggestion of the correct type, return early
if (suggestionData === null || suggestionData.mappedSuggestion.type !== `custom`) {
return;
}
const { node, mappedSuggestion } = suggestionData;
const existingContent = node.textContent;
if (existingContent == null) {
return;
}
// replace the emoticon with the suggesed emoji
const newContent =
existingContent.slice(0, suggestionData.startOffset) +
mappedSuggestion.text +
existingContent.slice(suggestionData.endOffset);
node.textContent = newContent;
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
setText(newContent);
setSuggestionData(null);
}
/**
* Given some text content from a node and the cursor position, find the word that the cursor is currently inside
* and then test that word to see if it is a suggestion. Return the `MappedSuggestion` with start and end offsets if
* the cursor is inside a valid suggestion, null otherwise.
*
* @param text - the text content of a node
* @param offset - the current cursor offset position within the node
* @param isFirstTextNode - whether or not the node is the first text node in the editor. Used to determine
* if a command suggestion is found or not
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
* @returns the `MappedSuggestion` along with its start and end offsets if found, otherwise null
*/
export function findSuggestionInText(
text: string,
offset: number,
isFirstTextNode: boolean,
isAutoReplaceEmojiEnabled?: boolean,
): { mappedSuggestion: MappedSuggestion; startOffset: number; endOffset: number } | null {
// Return null early if the offset is outside the content
if (offset < 0 || offset > text.length) {
return null;
}
// Variables to keep track of the indices we will be slicing from and to in order to create
// a substring of the word that the cursor is currently inside
let startSliceIndex = offset;
let endSliceIndex = offset;
// Search backwards from the current cursor position to find the start index of the word
// containing the cursor
while (shouldDecrementStartIndex(text, startSliceIndex)) {
startSliceIndex--;
}
// Search forwards from the current cursor position to find the end index of the word
// containing the cursor
while (shouldIncrementEndIndex(text, endSliceIndex)) {
endSliceIndex++;
}
// Get the word at the cursor then check if it contains a suggestion or not
const wordAtCursor = text.slice(startSliceIndex, endSliceIndex);
const mappedSuggestion = getMappedSuggestion(wordAtCursor, isAutoReplaceEmojiEnabled);
/**
* If we have a word that could be a command, it is not a valid command if:
* - the node we're looking at isn't the first text node in the editor (adding paragraphs can
* result in nested <p> tags inside the editor <div>)
* - the starting index is anything other than 0 (they can only appear at the start of a message)
* - there is more text following the command (eg `/spo asdf|` should not be interpreted as
* something requiring autocomplete)
*/
if (
mappedSuggestion === null ||
(mappedSuggestion.type === "command" &&
(!isFirstTextNode || startSliceIndex !== 0 || endSliceIndex !== text.length))
) {
return null;
}
return { mappedSuggestion, startOffset: startSliceIndex, endOffset: startSliceIndex + wordAtCursor.length };
}
/**
* Associated function for findSuggestionInText. Checks the character at the preceding index
* to determine if the search loop should continue.
*
* @param text - text content to check for mentions or commands
* @param index - the current index to check
* @returns true if check should keep moving backwards, false otherwise
*/
function shouldDecrementStartIndex(text: string, index: number): boolean {
// If the index is at or outside the beginning of the string, return false
if (index <= 0) return false;
// We are inside the string so can guarantee that there is a preceding character
// Keep searching backwards if the preceding character is not a space
return !/\s/.test(text[index - 1]);
}
/**
* Associated function for findSuggestionInText. Checks the character at the current index
* to determine if the search loop should continue.
*
* @param text - text content to check for mentions or commands
* @param index - the current index to check
* @returns true if check should keep moving forwards, false otherwise
*/
function shouldIncrementEndIndex(text: string, index: number): boolean {
// If the index is at or outside the end of the string, return false
if (index >= text.length) return false;
// Keep searching forwards if the current character is not a space
return !/\s/.test(text[index]);
}
/**
* Given a string, return a `MappedSuggestion` if the string contains a suggestion. Otherwise return null.
*
* @param text - string to check for a suggestion
* @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
* @returns a `MappedSuggestion` if a suggestion is present, null otherwise
*/
export function getMappedSuggestion(text: string, isAutoReplaceEmojiEnabled?: boolean): MappedSuggestion | null {
if (isAutoReplaceEmojiEnabled) {
const emoji = EMOTICON_TO_EMOJI.get(text.toLocaleLowerCase());
if (emoji?.unicode) {
return { keyChar: "", text: emoji.unicode, type: "custom" };
}
}
const firstChar = text.charAt(0);
const restOfString = text.slice(1);
switch (firstChar) {
case "/":
return { keyChar: firstChar, text: restOfString, type: "command" };
case "#":
case "@":
return { keyChar: firstChar, text: restOfString, type: "mention" };
default:
return null;
}
}