Room and user mentions for plain text editor (#10665)

* update useSuggestion

* update useSuggestion-tests

* add processMention tests

* add test

* add getMentionOrCommand tests

* change mock href for codeQL reasons

* fix TS issue in test

* add a big old cypress test

* fix lint error

* update comments

* reorganise functions in order of importance

* rename functions and variables

* add endOffset to return object

* fix failing tests

* update function names and comments

* update comment, remove delay

* update comments and early return

* nest mappedSuggestion inside Suggestion state and update test

* rename suggestion => suggestionData

* update comment

* add argument to findSuggestionInText

* make findSuggestionInText return mappedSuggestion

* fix TS error

* update comments and index check from === -1 to < 0

* tidy logic in increment functions

* rename variable

* Big refactor to address multiple comments, improve behaviour and add tests

* improve comments

* tidy up comment

* extend comment

* combine similar returns

* update comment

* remove single use variable

* fix comments
This commit is contained in:
alunturner 2023-05-11 15:28:42 +01:00 committed by GitHub
parent 68ff19fb4b
commit 0889dc55da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 506 additions and 126 deletions

View file

@ -20,13 +20,13 @@ import { SyntheticEvent, useState } from "react";
/**
* Information about the current state of the `useSuggestion` hook.
*/
export type Suggestion = 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.
*/
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;
@ -39,38 +39,37 @@ type SuggestionState = Suggestion | null;
* @param editorRef - a ref to the div that is the composer textbox
* @param setText - setter function to set the content of the composer
* @returns
* - `handleMention`: TODO a function that will insert @ or # mentions which are selected from
* the autocomplete into the composer
* - `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,
): {
handleMention: (link: string, text: string, attributes: Attributes) => void;
handleMention: (href: string, displayName: string, attributes: Attributes) => void;
handleCommand: (text: string) => void;
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null;
} {
const [suggestion, setSuggestion] = useState<SuggestionState>(null);
const [suggestionData, setSuggestionData] = useState<SuggestionState>(null);
// TODO handle the mentions (@user, #room etc)
const handleMention = (): void => {};
// We create a `seletionchange` handler here because we need to know when the user has moved the cursor,
// 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, suggestion, setSuggestion);
const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData);
const handleMention = (href: string, displayName: string, attributes: Attributes): void =>
processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText);
const handleCommand = (replacementText: string): void =>
processCommand(replacementText, suggestion, setSuggestion, setText);
processCommand(replacementText, suggestionData, setSuggestionData, setText);
return {
suggestion: mapSuggestion(suggestion),
suggestion: suggestionData?.mappedSuggestion ?? null,
handleCommand,
handleMention,
onSelect,
@ -78,41 +77,118 @@ export function useSuggestion(
}
/**
* Convert a PlainTextSuggestionPattern (or null) to a MappedSuggestion (or null)
* 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 suggestion - the suggestion that is the JS equivalent of the rust model's representation
* @returns - null if the input is null, a MappedSuggestion if the input is non-null
* @param editorRef - ref to the composer
* @param setSuggestionData - the setter for the suggestion state
*/
export const mapSuggestion = (suggestion: SuggestionState): MappedSuggestion | null => {
if (suggestion === null) {
return null;
} else {
const { node, startOffset, endOffset, ...mappedSuggestion } = suggestion;
return mappedSuggestion;
export function processSelectionChange(
editorRef: React.RefObject<HTMLDivElement>,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
): 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);
// 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: Attributes, // 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;
const textBeforeReplacement = node.textContent?.slice(0, suggestionData.startOffset) ?? "";
const textAfterReplacement = node.textContent?.slice(suggestionData.endOffset) ?? "";
// TODO replace this markdown style text insertion with a pill representation
const newText = `[${displayName}](<${href}>) `;
const newCursorOffset = textBeforeReplacement.length + newText.length;
const newContent = textBeforeReplacement + newText + textAfterReplacement;
// insert the new text, move the cursor, set the text state, clear the suggestion state
node.textContent = newContent;
document.getSelection()?.setBaseAndExtent(node, newCursorOffset, node, newCursorOffset);
setText(newContent);
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 suggestion - representation of the part of the DOM that will be replaced
* @param setSuggestion - setter function to set the suggestion state
* @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 const processCommand = (
export function processCommand(
replacementText: string,
suggestion: SuggestionState,
setSuggestion: React.Dispatch<React.SetStateAction<SuggestionState>>,
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text: string) => void,
): void => {
): void {
// if we do not have a suggestion, return early
if (suggestion === null) {
if (suggestionData === null) {
return;
}
const { node } = suggestion;
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
@ -123,70 +199,120 @@ export const processCommand = (
// hook and clear the suggestion from state
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
setText(newContent);
setSuggestion(null);
};
setSuggestionData(null);
}
/**
* When the selection changes inside the current editor, check to see if the cursor is inside
* something that could require the autocomplete to be opened and update the suggestion state
* if so
* TODO expand this to handle mentions
* 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 editorRef - ref to the composer
* @param suggestion - the current suggestion state
* @param setSuggestion - the setter for the suggestion state
* @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
* @returns the `MappedSuggestion` along with its start and end offsets if found, otherwise null
*/
export const processSelectionChange = (
editorRef: React.RefObject<HTMLDivElement>,
suggestion: SuggestionState,
setSuggestion: React.Dispatch<React.SetStateAction<SuggestionState>>,
): void => {
const selection = document.getSelection();
export function findSuggestionInText(
text: string,
offset: number,
isFirstTextNode: 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;
}
// return early if we do not have a current editor ref with a cursor selection inside a text node
// 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);
/**
* 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 (
editorRef.current === null ||
selection === null ||
!selection.isCollapsed ||
selection.anchorNode?.nodeName !== "#text"
mappedSuggestion === null ||
(mappedSuggestion.type === "command" &&
(!isFirstTextNode || startSliceIndex !== 0 || endSliceIndex !== text.length))
) {
return;
return null;
}
// here we have established that both anchor and focus nodes in the selection are
// the same node, so rename to `currentNode` for later use
const { anchorNode: currentNode } = selection;
return { mappedSuggestion, startOffset: startSliceIndex, endOffset: startSliceIndex + wordAtCursor.length };
}
// first check is that the text node is the first text node of the editor, as adding paragraphs can result
// in nested <p> tags inside the editor <div>
const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();
/**
* 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;
// if we're not in the first text node or we have no text content, return
if (currentNode !== firstTextNode || currentNode.textContent === null) {
return;
// 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
* @returns a `MappedSuggestion` if a suggestion is present, null otherwise
*/
export function getMappedSuggestion(text: string): MappedSuggestion | null {
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;
}
// it's a command if:
// it is the first textnode AND
// it starts with /, not // AND
// then has letters all the way up to the end of the textcontent
const commandRegex = /^\/(\w*)$/;
const commandMatches = currentNode.textContent.match(commandRegex);
// if we don't have any matches, return, clearing the suggeston state if it is non-null
if (commandMatches === null) {
if (suggestion !== null) {
setSuggestion(null);
}
return;
} else {
setSuggestion({
keyChar: "/",
type: "command",
text: commandMatches[1],
node: selection.anchorNode,
startOffset: 0,
endOffset: currentNode.textContent.length,
});
}
};
}