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:
parent
68ff19fb4b
commit
0889dc55da
3 changed files with 506 additions and 126 deletions
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue