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
|
@ -15,9 +15,11 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/// <reference types="cypress" />
|
/// <reference types="cypress" />
|
||||||
|
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
|
||||||
import { HomeserverInstance } from "../../plugins/utils/homeserver";
|
import { HomeserverInstance } from "../../plugins/utils/homeserver";
|
||||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||||
|
import { MatrixClient } from "../../global";
|
||||||
|
|
||||||
describe("Composer", () => {
|
describe("Composer", () => {
|
||||||
let homeserver: HomeserverInstance;
|
let homeserver: HomeserverInstance;
|
||||||
|
@ -181,6 +183,81 @@ describe("Composer", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Mentions", () => {
|
||||||
|
// TODO add tests for rich text mode
|
||||||
|
|
||||||
|
describe("Plain text mode", () => {
|
||||||
|
it("autocomplete behaviour tests", () => {
|
||||||
|
// Setup a private room so we have another user to mention
|
||||||
|
const otherUserName = "Bob";
|
||||||
|
let bobClient: MatrixClient;
|
||||||
|
cy.getBot(homeserver, {
|
||||||
|
displayName: otherUserName,
|
||||||
|
}).then((bob) => {
|
||||||
|
bobClient = bob;
|
||||||
|
});
|
||||||
|
// create DM with bob
|
||||||
|
cy.getClient().then(async (cli) => {
|
||||||
|
const bobRoom = await cli.createRoom({ is_direct: true });
|
||||||
|
await cli.invite(bobRoom.room_id, bobClient.getUserId());
|
||||||
|
await cli.setAccountData("m.direct" as EventType, {
|
||||||
|
[bobClient.getUserId()]: [bobRoom.room_id],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.viewRoomByName("Bob");
|
||||||
|
|
||||||
|
// Select plain text mode after composer is ready
|
||||||
|
cy.get("div[contenteditable=true]").should("exist");
|
||||||
|
cy.findByRole("button", { name: "Hide formatting" }).click();
|
||||||
|
|
||||||
|
// Typing a single @ does not display the autocomplete menu and contents
|
||||||
|
cy.findByRole("textbox").type("@");
|
||||||
|
cy.findByTestId("autocomplete-wrapper").should("be.empty");
|
||||||
|
|
||||||
|
// Entering the first letter of the other user's name opens the autocomplete...
|
||||||
|
cy.findByRole("textbox").type(otherUserName.slice(0, 1));
|
||||||
|
cy.findByTestId("autocomplete-wrapper")
|
||||||
|
.should("not.be.empty")
|
||||||
|
.within(() => {
|
||||||
|
// ...with the other user name visible, and clicking that username...
|
||||||
|
cy.findByText(otherUserName).should("exist").click();
|
||||||
|
});
|
||||||
|
// ...inserts the username into the composer
|
||||||
|
cy.findByRole("textbox").within(() => {
|
||||||
|
// TODO update this test when the mentions are inserted as pills, instead
|
||||||
|
// of as text
|
||||||
|
cy.findByText(otherUserName, { exact: false }).should("exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the message to clear the composer
|
||||||
|
cy.findByRole("button", { name: "Send message" }).click();
|
||||||
|
|
||||||
|
// Typing an @, then other user's name, then trailing space closes the autocomplete
|
||||||
|
cy.findByRole("textbox").type(`@${otherUserName} `);
|
||||||
|
cy.findByTestId("autocomplete-wrapper").should("be.empty");
|
||||||
|
|
||||||
|
// Send the message to clear the composer
|
||||||
|
cy.findByRole("button", { name: "Send message" }).click();
|
||||||
|
|
||||||
|
// Moving the cursor back to an "incomplete" mention opens the autocomplete
|
||||||
|
cy.findByRole("textbox").type(`initial text @${otherUserName.slice(0, 1)} abc`);
|
||||||
|
cy.findByTestId("autocomplete-wrapper").should("be.empty");
|
||||||
|
// Move the cursor left by 4 to put it to: `@B| abc`, check autocomplete displays
|
||||||
|
cy.findByRole("textbox").type(`${"{leftArrow}".repeat(4)}`);
|
||||||
|
cy.findByTestId("autocomplete-wrapper").should("not.be.empty");
|
||||||
|
|
||||||
|
// Selecting the autocomplete option using Enter inserts it into the composer
|
||||||
|
cy.findByRole("textbox").type(`{Enter}`);
|
||||||
|
cy.findByRole("textbox").within(() => {
|
||||||
|
// TODO update this test when the mentions are inserted as pills, instead
|
||||||
|
// of as text
|
||||||
|
cy.findByText(otherUserName, { exact: false }).should("exist");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("sends a message when you click send or press Enter", () => {
|
it("sends a message when you click send or press Enter", () => {
|
||||||
// Type a message
|
// Type a message
|
||||||
cy.get("div[contenteditable=true]").type("my message 0");
|
cy.get("div[contenteditable=true]").type("my message 0");
|
||||||
|
|
|
@ -20,12 +20,12 @@ import { SyntheticEvent, useState } from "react";
|
||||||
/**
|
/**
|
||||||
* Information about the current state of the `useSuggestion` hook.
|
* Information about the current state of the `useSuggestion` hook.
|
||||||
*/
|
*/
|
||||||
export type Suggestion = MappedSuggestion & {
|
export type Suggestion = {
|
||||||
/**
|
mappedSuggestion: MappedSuggestion;
|
||||||
* The information in a `MappedSuggestion` is sufficient to generate a query for the autocomplete
|
/* 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
|
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
|
when selecting an option from the autocomplete. These three pieces of information allow us to
|
||||||
* do that.
|
do that.
|
||||||
*/
|
*/
|
||||||
node: Node;
|
node: Node;
|
||||||
startOffset: number;
|
startOffset: number;
|
||||||
|
@ -39,38 +39,37 @@ type SuggestionState = Suggestion | null;
|
||||||
* @param editorRef - a ref to the div that is the composer textbox
|
* @param editorRef - a ref to the div that is the composer textbox
|
||||||
* @param setText - setter function to set the content of the composer
|
* @param setText - setter function to set the content of the composer
|
||||||
* @returns
|
* @returns
|
||||||
* - `handleMention`: TODO a function that will insert @ or # mentions which are selected from
|
* - `handleMention`: a function that will insert @ or # mentions which are selected from
|
||||||
* the autocomplete into the composer
|
* 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.
|
* - `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
|
* Can be used to process autocomplete of slash commands
|
||||||
* - `onSelect`: a selection change listener to be attached to the plain text composer
|
* - `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,
|
* - `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
|
* this will be an object representing that command or mention, otherwise it is null
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function useSuggestion(
|
export function useSuggestion(
|
||||||
editorRef: React.RefObject<HTMLDivElement>,
|
editorRef: React.RefObject<HTMLDivElement>,
|
||||||
setText: (text: string) => void,
|
setText: (text: string) => void,
|
||||||
): {
|
): {
|
||||||
handleMention: (link: string, text: string, attributes: Attributes) => void;
|
handleMention: (href: string, displayName: string, attributes: Attributes) => void;
|
||||||
handleCommand: (text: string) => void;
|
handleCommand: (text: string) => void;
|
||||||
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
|
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
|
||||||
suggestion: MappedSuggestion | null;
|
suggestion: MappedSuggestion | null;
|
||||||
} {
|
} {
|
||||||
const [suggestion, setSuggestion] = useState<SuggestionState>(null);
|
const [suggestionData, setSuggestionData] = useState<SuggestionState>(null);
|
||||||
|
|
||||||
// TODO handle the mentions (@user, #room etc)
|
// We create a `selectionchange` handler here because we need to know when the user has moved the cursor,
|
||||||
const handleMention = (): void => {};
|
|
||||||
|
|
||||||
// We create a `seletionchange` handler here because we need to know when the user has moved the cursor,
|
|
||||||
// we can not depend on input events only
|
// 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 =>
|
const handleCommand = (replacementText: string): void =>
|
||||||
processCommand(replacementText, suggestion, setSuggestion, setText);
|
processCommand(replacementText, suggestionData, setSuggestionData, setText);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
suggestion: mapSuggestion(suggestion),
|
suggestion: suggestionData?.mappedSuggestion ?? null,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
handleMention,
|
handleMention,
|
||||||
onSelect,
|
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
|
* @param editorRef - ref to the composer
|
||||||
* @returns - null if the input is null, a MappedSuggestion if the input is non-null
|
* @param setSuggestionData - the setter for the suggestion state
|
||||||
*/
|
*/
|
||||||
export const mapSuggestion = (suggestion: SuggestionState): MappedSuggestion | null => {
|
export function processSelectionChange(
|
||||||
if (suggestion === null) {
|
editorRef: React.RefObject<HTMLDivElement>,
|
||||||
return null;
|
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
|
||||||
} else {
|
): void {
|
||||||
const { node, startOffset, endOffset, ...mappedSuggestion } = suggestion;
|
const selection = document.getSelection();
|
||||||
return mappedSuggestion;
|
|
||||||
|
// 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
|
* Replaces the relevant part of the editor text with the replacement text after a command is selected
|
||||||
* from the autocomplete.
|
* from the autocomplete.
|
||||||
*
|
*
|
||||||
* @param replacementText - the text that we will insert into the DOM
|
* @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 suggestionData - representation of the part of the DOM that will be replaced
|
||||||
* @param setSuggestion - setter function to set the suggestion state
|
* @param setSuggestionData - setter function to set the suggestion state
|
||||||
* @param setText - setter function to set the content of the composer
|
* @param setText - setter function to set the content of the composer
|
||||||
*/
|
*/
|
||||||
export const processCommand = (
|
export function processCommand(
|
||||||
replacementText: string,
|
replacementText: string,
|
||||||
suggestion: SuggestionState,
|
suggestionData: SuggestionState,
|
||||||
setSuggestion: React.Dispatch<React.SetStateAction<SuggestionState>>,
|
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
|
||||||
setText: (text: string) => void,
|
setText: (text: string) => void,
|
||||||
): void => {
|
): void {
|
||||||
// if we do not have a suggestion, return early
|
// if we do not have a suggestion, return early
|
||||||
if (suggestion === null) {
|
if (suggestionData === null) {
|
||||||
return;
|
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
|
// 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
|
// 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
|
// hook and clear the suggestion from state
|
||||||
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
|
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
|
||||||
setText(newContent);
|
setText(newContent);
|
||||||
setSuggestion(null);
|
setSuggestionData(null);
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the selection changes inside the current editor, check to see if the cursor is inside
|
* Given some text content from a node and the cursor position, find the word that the cursor is currently inside
|
||||||
* something that could require the autocomplete to be opened and update the suggestion state
|
* and then test that word to see if it is a suggestion. Return the `MappedSuggestion` with start and end offsets if
|
||||||
* if so
|
* the cursor is inside a valid suggestion, null otherwise.
|
||||||
* TODO expand this to handle mentions
|
|
||||||
*
|
*
|
||||||
* @param editorRef - ref to the composer
|
* @param text - the text content of a node
|
||||||
* @param suggestion - the current suggestion state
|
* @param offset - the current cursor offset position within the node
|
||||||
* @param setSuggestion - the setter for the suggestion state
|
* @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 = (
|
export function findSuggestionInText(
|
||||||
editorRef: React.RefObject<HTMLDivElement>,
|
text: string,
|
||||||
suggestion: SuggestionState,
|
offset: number,
|
||||||
setSuggestion: React.Dispatch<React.SetStateAction<SuggestionState>>,
|
isFirstTextNode: boolean,
|
||||||
): void => {
|
): { mappedSuggestion: MappedSuggestion; startOffset: number; endOffset: number } | null {
|
||||||
const selection = document.getSelection();
|
// 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 (
|
if (
|
||||||
editorRef.current === null ||
|
mappedSuggestion === null ||
|
||||||
selection === null ||
|
(mappedSuggestion.type === "command" &&
|
||||||
!selection.isCollapsed ||
|
(!isFirstTextNode || startSliceIndex !== 0 || endSliceIndex !== text.length))
|
||||||
selection.anchorNode?.nodeName !== "#text"
|
|
||||||
) {
|
) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// here we have established that both anchor and focus nodes in the selection are
|
return { mappedSuggestion, startOffset: startSliceIndex, endOffset: startSliceIndex + wordAtCursor.length };
|
||||||
// the same node, so rename to `currentNode` for later use
|
}
|
||||||
const { anchorNode: currentNode } = selection;
|
|
||||||
|
|
||||||
// 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>
|
* Associated function for findSuggestionInText. Checks the character at the preceding index
|
||||||
const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();
|
* 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
|
// We are inside the string so can guarantee that there is a preceding character
|
||||||
if (currentNode !== firstTextNode || currentNode.textContent === null) {
|
// Keep searching backwards if the preceding character is not a space
|
||||||
return;
|
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
@ -17,16 +17,16 @@ import React from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Suggestion,
|
Suggestion,
|
||||||
mapSuggestion,
|
findSuggestionInText,
|
||||||
|
getMappedSuggestion,
|
||||||
processCommand,
|
processCommand,
|
||||||
|
processMention,
|
||||||
processSelectionChange,
|
processSelectionChange,
|
||||||
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
|
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
|
||||||
|
|
||||||
function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
|
function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
|
||||||
return {
|
return {
|
||||||
keyChar: "/",
|
mappedSuggestion: { keyChar: "/", type: "command", text: "some text", ...props.mappedSuggestion },
|
||||||
type: "command",
|
|
||||||
text: "some text",
|
|
||||||
node: document.createTextNode(""),
|
node: document.createTextNode(""),
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: 0,
|
endOffset: 0,
|
||||||
|
@ -34,24 +34,6 @@ function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}):
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("mapSuggestion", () => {
|
|
||||||
it("returns null if called with a null argument", () => {
|
|
||||||
expect(mapSuggestion(null)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a mapped suggestion when passed a suggestion", () => {
|
|
||||||
const inputFields = {
|
|
||||||
keyChar: "/" as const,
|
|
||||||
type: "command" as const,
|
|
||||||
text: "some text",
|
|
||||||
};
|
|
||||||
const input = createMockPlainTextSuggestionPattern(inputFields);
|
|
||||||
const output = mapSuggestion(input);
|
|
||||||
|
|
||||||
expect(output).toEqual(inputFields);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("processCommand", () => {
|
describe("processCommand", () => {
|
||||||
it("does not change parent hook state if suggestion is null", () => {
|
it("does not change parent hook state if suggestion is null", () => {
|
||||||
// create a mockSuggestion using the text node above
|
// create a mockSuggestion using the text node above
|
||||||
|
@ -85,6 +67,48 @@ describe("processCommand", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("processMention", () => {
|
||||||
|
// TODO refactor and expand tests when mentions become <a> tags
|
||||||
|
it("returns early when suggestion is null", () => {
|
||||||
|
const mockSetSuggestion = jest.fn();
|
||||||
|
const mockSetText = jest.fn();
|
||||||
|
processMention("href", "displayName", {}, null, mockSetSuggestion, mockSetText);
|
||||||
|
|
||||||
|
expect(mockSetSuggestion).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can insert a mention into an empty text node", () => {
|
||||||
|
// make an empty text node, set the cursor inside it and then append to the document
|
||||||
|
const textNode = document.createTextNode("");
|
||||||
|
document.body.appendChild(textNode);
|
||||||
|
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 0);
|
||||||
|
|
||||||
|
// call the util function
|
||||||
|
const href = "href";
|
||||||
|
const displayName = "displayName";
|
||||||
|
const mockSetSuggestion = jest.fn();
|
||||||
|
const mockSetText = jest.fn();
|
||||||
|
processMention(
|
||||||
|
href,
|
||||||
|
displayName,
|
||||||
|
{},
|
||||||
|
{ node: textNode, startOffset: 0, endOffset: 0 } as unknown as Suggestion,
|
||||||
|
mockSetSuggestion,
|
||||||
|
mockSetText,
|
||||||
|
);
|
||||||
|
|
||||||
|
// placeholder testing for the changed content - these tests will all be changed
|
||||||
|
// when the mention is inserted as an <a> tagfs
|
||||||
|
const { textContent } = textNode;
|
||||||
|
expect(textContent!.includes(href)).toBe(true);
|
||||||
|
expect(textContent!.includes(displayName)).toBe(true);
|
||||||
|
|
||||||
|
expect(mockSetText).toHaveBeenCalledWith(expect.stringContaining(displayName));
|
||||||
|
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("processSelectionChange", () => {
|
describe("processSelectionChange", () => {
|
||||||
function createMockEditorRef(element: HTMLDivElement | null = null): React.RefObject<HTMLDivElement> {
|
function createMockEditorRef(element: HTMLDivElement | null = null): React.RefObject<HTMLDivElement> {
|
||||||
return { current: element } as React.RefObject<HTMLDivElement>;
|
return { current: element } as React.RefObject<HTMLDivElement>;
|
||||||
|
@ -112,14 +136,14 @@ describe("processSelectionChange", () => {
|
||||||
// we monitor for the call to document.createNodeIterator to indicate an early return
|
// we monitor for the call to document.createNodeIterator to indicate an early return
|
||||||
const nodeIteratorSpy = jest.spyOn(document, "createNodeIterator");
|
const nodeIteratorSpy = jest.spyOn(document, "createNodeIterator");
|
||||||
|
|
||||||
processSelectionChange(mockEditorRef, null, jest.fn());
|
processSelectionChange(mockEditorRef, jest.fn());
|
||||||
expect(nodeIteratorSpy).not.toHaveBeenCalled();
|
expect(nodeIteratorSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// tidy up to avoid potential impacts on other tests
|
// tidy up to avoid potential impacts on other tests
|
||||||
nodeIteratorSpy.mockRestore();
|
nodeIteratorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not call setSuggestion if selection is not a cursor", () => {
|
it("calls setSuggestion with null if selection is not a cursor", () => {
|
||||||
const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
|
const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
|
||||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||||
|
|
||||||
|
@ -128,11 +152,11 @@ describe("processSelectionChange", () => {
|
||||||
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 4);
|
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 4);
|
||||||
|
|
||||||
// process the selection and check that we do not attempt to set the suggestion
|
// process the selection and check that we do not attempt to set the suggestion
|
||||||
processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
|
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||||
expect(mockSetSuggestion).not.toHaveBeenCalled();
|
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not call setSuggestion if selection cursor is not inside a text node", () => {
|
it("calls setSuggestion with null if selection cursor is not inside a text node", () => {
|
||||||
const [mockEditor] = appendEditorWithTextNodeContaining("content");
|
const [mockEditor] = appendEditorWithTextNodeContaining("content");
|
||||||
const mockEditorRef = createMockEditorRef(mockEditor);
|
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||||
|
|
||||||
|
@ -140,8 +164,8 @@ describe("processSelectionChange", () => {
|
||||||
document.getSelection()?.setBaseAndExtent(mockEditor, 0, mockEditor, 0);
|
document.getSelection()?.setBaseAndExtent(mockEditor, 0, mockEditor, 0);
|
||||||
|
|
||||||
// process the selection and check that we do not attempt to set the suggestion
|
// process the selection and check that we do not attempt to set the suggestion
|
||||||
processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
|
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||||
expect(mockSetSuggestion).not.toHaveBeenCalled();
|
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls setSuggestion with null if we have an existing suggestion but no command match", () => {
|
it("calls setSuggestion with null if we have an existing suggestion but no command match", () => {
|
||||||
|
@ -153,7 +177,7 @@ describe("processSelectionChange", () => {
|
||||||
|
|
||||||
// the call to process the selection will have an existing suggestion in state due to the second
|
// the call to process the selection will have an existing suggestion in state due to the second
|
||||||
// argument being non-null, expect that we clear this suggestion now that the text is not a command
|
// argument being non-null, expect that we clear this suggestion now that the text is not a command
|
||||||
processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
|
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||||
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -166,14 +190,167 @@ describe("processSelectionChange", () => {
|
||||||
document.getSelection()?.setBaseAndExtent(textNode, 3, textNode, 3);
|
document.getSelection()?.setBaseAndExtent(textNode, 3, textNode, 3);
|
||||||
|
|
||||||
// process the change and check the suggestion that is set looks as we expect it to
|
// process the change and check the suggestion that is set looks as we expect it to
|
||||||
processSelectionChange(mockEditorRef, null, mockSetSuggestion);
|
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||||
expect(mockSetSuggestion).toHaveBeenCalledWith({
|
expect(mockSetSuggestion).toHaveBeenCalledWith({
|
||||||
|
mappedSuggestion: {
|
||||||
keyChar: "/",
|
keyChar: "/",
|
||||||
type: "command",
|
type: "command",
|
||||||
text: "potentialCommand",
|
text: "potentialCommand",
|
||||||
|
},
|
||||||
node: textNode,
|
node: textNode,
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
endOffset: commandText.length,
|
endOffset: commandText.length,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not treat a command outside the first text node to be a suggestion", () => {
|
||||||
|
const [mockEditor] = appendEditorWithTextNodeContaining("some text in first node");
|
||||||
|
const [, commandTextNode] = appendEditorWithTextNodeContaining("/potentialCommand");
|
||||||
|
|
||||||
|
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||||
|
|
||||||
|
// create a selection in the text node that has identical start and end locations, ie it is a cursor
|
||||||
|
document.getSelection()?.setBaseAndExtent(commandTextNode, 3, commandTextNode, 3);
|
||||||
|
|
||||||
|
// process the change and check the suggestion that is set looks as we expect it to
|
||||||
|
processSelectionChange(mockEditorRef, mockSetSuggestion);
|
||||||
|
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findSuggestionInText", () => {
|
||||||
|
const command = "/someCommand";
|
||||||
|
const userMention = "@userMention";
|
||||||
|
const roomMention = "#roomMention";
|
||||||
|
|
||||||
|
const mentionTestCases = [userMention, roomMention];
|
||||||
|
const allTestCases = [command, userMention, roomMention];
|
||||||
|
|
||||||
|
it("returns null if content does not contain any mention or command characters", () => {
|
||||||
|
expect(findSuggestionInText("hello", 1, true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null if content contains a command but is not the first text node", () => {
|
||||||
|
expect(findSuggestionInText(command, 1, false)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null if the offset is outside the content length", () => {
|
||||||
|
expect(findSuggestionInText("hi", 30, true)).toBeNull();
|
||||||
|
expect(findSuggestionInText("hi", -10, true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(allTestCases)("returns an object when the whole input is special case: %s", (text) => {
|
||||||
|
const expected = {
|
||||||
|
mappedSuggestion: getMappedSuggestion(text),
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: text.length,
|
||||||
|
};
|
||||||
|
// test for cursor immediately before and after special character, before end, at end
|
||||||
|
expect(findSuggestionInText(text, 0, true)).toEqual(expected);
|
||||||
|
expect(findSuggestionInText(text, 1, true)).toEqual(expected);
|
||||||
|
expect(findSuggestionInText(text, text.length - 2, true)).toEqual(expected);
|
||||||
|
expect(findSuggestionInText(text, text.length, true)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when a command is followed by other text", () => {
|
||||||
|
const followingText = " followed by something";
|
||||||
|
|
||||||
|
// check for cursor inside and outside the command
|
||||||
|
expect(findSuggestionInText(command + followingText, command.length - 2, true)).toBeNull();
|
||||||
|
expect(findSuggestionInText(command + followingText, command.length + 2, true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(mentionTestCases)("returns an object when a %s is followed by other text", (mention) => {
|
||||||
|
const followingText = " followed by something else";
|
||||||
|
expect(findSuggestionInText(mention + followingText, mention.length - 2, true)).toEqual({
|
||||||
|
mappedSuggestion: getMappedSuggestion(mention),
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: mention.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null if there is a command surrounded by text", () => {
|
||||||
|
const precedingText = "text before the command ";
|
||||||
|
const followingText = " text after the command";
|
||||||
|
expect(
|
||||||
|
findSuggestionInText(precedingText + command + followingText, precedingText.length + 4, true),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(mentionTestCases)("returns an object if %s is surrounded by text", (mention) => {
|
||||||
|
const precedingText = "I want to mention ";
|
||||||
|
const followingText = " in my message";
|
||||||
|
|
||||||
|
const textInput = precedingText + mention + followingText;
|
||||||
|
const expected = {
|
||||||
|
mappedSuggestion: getMappedSuggestion(mention),
|
||||||
|
startOffset: precedingText.length,
|
||||||
|
endOffset: precedingText.length + mention.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
// when the cursor is immediately before the special character
|
||||||
|
expect(findSuggestionInText(textInput, precedingText.length, true)).toEqual(expected);
|
||||||
|
// when the cursor is inside the mention
|
||||||
|
expect(findSuggestionInText(textInput, precedingText.length + 3, true)).toEqual(expected);
|
||||||
|
// when the cursor is right at the end of the mention
|
||||||
|
expect(findSuggestionInText(textInput, precedingText.length + mention.length, true)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for text content with an email address", () => {
|
||||||
|
const emailInput = "send to user@test.com";
|
||||||
|
expect(findSuggestionInText(emailInput, 15, true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for double slashed command", () => {
|
||||||
|
const doubleSlashCommand = "//not a command";
|
||||||
|
expect(findSuggestionInText(doubleSlashCommand, 4, true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for slash separated text", () => {
|
||||||
|
const slashSeparatedInput = "please to this/that/the other";
|
||||||
|
expect(findSuggestionInText(slashSeparatedInput, 21, true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an object for a mention that contains punctuation", () => {
|
||||||
|
const mentionWithPunctuation = "@userX14#5a_-";
|
||||||
|
const precedingText = "mention ";
|
||||||
|
const mentionInput = precedingText + mentionWithPunctuation;
|
||||||
|
expect(findSuggestionInText(mentionInput, 12, true)).toEqual({
|
||||||
|
mappedSuggestion: getMappedSuggestion(mentionWithPunctuation),
|
||||||
|
startOffset: precedingText.length,
|
||||||
|
endOffset: precedingText.length + mentionWithPunctuation.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when user inputs any whitespace after the special character", () => {
|
||||||
|
const mentionWithSpaceAfter = "@ somebody";
|
||||||
|
expect(findSuggestionInText(mentionWithSpaceAfter, 2, true)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMappedSuggestion", () => {
|
||||||
|
it("returns null when the first character is not / # @", () => {
|
||||||
|
expect(getMappedSuggestion("Zzz")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the expected mapped suggestion when first character is # or @", () => {
|
||||||
|
expect(getMappedSuggestion("@user-mention")).toEqual({
|
||||||
|
type: "mention",
|
||||||
|
keyChar: "@",
|
||||||
|
text: "user-mention",
|
||||||
|
});
|
||||||
|
expect(getMappedSuggestion("#room-mention")).toEqual({
|
||||||
|
type: "mention",
|
||||||
|
keyChar: "#",
|
||||||
|
text: "room-mention",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the expected mapped suggestion when first character is /", () => {
|
||||||
|
expect(getMappedSuggestion("/command")).toEqual({
|
||||||
|
type: "command",
|
||||||
|
keyChar: "/",
|
||||||
|
text: "command",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue