Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Room and user mentions for plain text editor #10665

Merged
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
2cfa778
update useSuggestion
Apr 27, 2023
b5c1f59
update useSuggestion-tests
Apr 27, 2023
7e846c0
add processMention tests
Apr 27, 2023
e4c65d2
add test
Apr 27, 2023
897f9f0
add getMentionOrCommand tests
Apr 27, 2023
e184a41
change mock href for codeQL reasons
Apr 27, 2023
8c1b3e6
fix TS issue in test
Apr 27, 2023
c0d0517
add a big old cypress test
Apr 28, 2023
47eeafe
Merge remote-tracking branch 'origin/develop' into alunturner/room-an…
Apr 28, 2023
e605977
fix lint error
Apr 28, 2023
f3a7c6f
update comments
Apr 28, 2023
2b281d8
reorganise functions in order of importance
Apr 28, 2023
c2ab234
rename functions and variables
Apr 28, 2023
790cbf3
Merge remote-tracking branch 'origin/develop' into alunturner/room-an…
May 5, 2023
4b8af0b
add endOffset to return object
May 5, 2023
89c2c78
fix failing tests
May 5, 2023
e0fe483
Merge remote-tracking branch 'origin/develop' into alunturner/room-an…
May 5, 2023
d1e5f52
update function names and comments
May 5, 2023
9f94d84
Merge branch 'develop' into alunturner/room-and-user-mentions-for-pla…
alunturner May 5, 2023
3a7370d
Merge branch 'develop' into alunturner/room-and-user-mentions-for-pla…
alunturner May 5, 2023
c531218
Merge remote-tracking branch 'origin/develop' into alunturner/room-an…
May 9, 2023
0e4aa06
Merge branch 'develop' into alunturner/room-and-user-mentions-for-pla…
alunturner May 9, 2023
97915ad
Merge remote-tracking branch 'origin/develop' into alunturner/room-an…
May 10, 2023
79b5560
update comment, remove delay
May 10, 2023
276140b
update comments and early return
May 10, 2023
b1b4e01
nest mappedSuggestion inside Suggestion state and update test
May 10, 2023
92e7836
rename suggestion => suggestionData
May 10, 2023
41d095a
update comment
May 10, 2023
24b6b7d
add argument to findSuggestionInText
May 10, 2023
1c785ea
make findSuggestionInText return mappedSuggestion
May 10, 2023
2368e25
fix TS error
May 10, 2023
12a7583
update comments and index check from === -1 to < 0
May 10, 2023
3d74710
tidy logic in increment functions
May 10, 2023
c0d240a
rename variable
May 10, 2023
d292763
Big refactor to address multiple comments, improve behaviour and add …
May 10, 2023
be88d71
improve comments
May 10, 2023
246ed7c
tidy up comment
May 10, 2023
283884f
extend comment
May 10, 2023
86017bd
combine similar returns
May 10, 2023
be67256
update comment
May 10, 2023
14f8ecd
remove single use variable
May 10, 2023
ce19e46
Merge remote-tracking branch 'origin/develop' into alunturner/room-an…
May 10, 2023
06e9af6
Merge branch 'develop' into alunturner/room-and-user-mentions-for-pla…
alunturner May 11, 2023
2658d62
Merge branch 'develop' into alunturner/room-and-user-mentions-for-pla…
May 11, 2023
45224cf
fix comments
May 11, 2023
3c15013
Merge branch 'develop' into alunturner/room-and-user-mentions-for-pla…
May 11, 2023
ed5caaa
Merge branch 'develop' into alunturner/room-and-user-mentions-for-pla…
alunturner May 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 163 additions & 50 deletions src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ limitations under the License.
*/

import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
import { SyntheticEvent, useState } from "react";
import { SyntheticEvent, useMemo, useState } from "react";

/**
* Information about the current state of the `useSuggestion` hook.
Expand Down Expand Up @@ -47,7 +47,6 @@ type SuggestionState = Suggestion | null;
* - `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,
Expand All @@ -59,38 +58,70 @@ export function useSuggestion(
} {
const [suggestion, setSuggestion] = useState<SuggestionState>(null);
alunturner marked this conversation as resolved.
Show resolved Hide resolved

// 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, setSuggestion);

const handleMention = (href: string, displayName: string, attributes: Attributes): void =>
processMention(href, displayName, attributes, suggestion, setSuggestion, setText);

const handleCommand = (replacementText: string): void =>
processCommand(replacementText, suggestion, setSuggestion, setText);

const memoizedMappedSuggestion: MappedSuggestion | null = useMemo(() => {
return suggestion !== null
? { keyChar: suggestion.keyChar, type: suggestion.type, text: suggestion.text }
: null;
}, [suggestion]);
alunturner marked this conversation as resolved.
Show resolved Hide resolved

return {
suggestion: mapSuggestion(suggestion),
suggestion: memoizedMappedSuggestion,
handleCommand,
handleMention,
alunturner marked this conversation as resolved.
Show resolved Hide resolved
onSelect,
};
}

/**
* Convert a PlainTextSuggestionPattern (or null) to a MappedSuggestion (or null)
* Replaces the relevant part of the editor text with a link representing a mention after it
* is selected from the autocomplete.
*
* @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 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 suggestion - representation of the part of the DOM that will be replaced
* @param setSuggestion - setter function to set the suggestion state
* @param setText - setter function to set the content of the composer
*/
export const mapSuggestion = (suggestion: SuggestionState): MappedSuggestion | null => {
alunturner marked this conversation as resolved.
Show resolved Hide resolved
export function processMention(
href: string,
displayName: string,
attributes: Attributes, // these will be used when formatting the link as a pill
suggestion: SuggestionState,
setSuggestion: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text: string) => void,
): void {
// if we do not have any of the values we need to do the work, do nothing
if (suggestion === null) {
return null;
} else {
const { node, startOffset, endOffset, ...mappedSuggestion } = suggestion;
return mappedSuggestion;
return;
}
};

const { node } = suggestion;

const textBeforeReplacement = node.textContent?.slice(0, suggestion.startOffset) ?? "";
const textAfterReplacement = node.textContent?.slice(suggestion.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);
setSuggestion(null);
}

/**
* Replaces the relevant part of the editor text with the replacement text after a command is selected
Expand All @@ -101,12 +132,12 @@ export const mapSuggestion = (suggestion: SuggestionState): MappedSuggestion | n
* @param setSuggestion - setter function to set the suggestion state
* @param setText - setter function to set the content of the composer
*/
export const processCommand = (
export function processCommand(
alunturner marked this conversation as resolved.
Show resolved Hide resolved
replacementText: string,
suggestion: SuggestionState,
setSuggestion: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text: string) => void,
): void => {
): void {
// if we do not have a suggestion, return early
if (suggestion === null) {
return;
Expand All @@ -124,23 +155,83 @@ export const processCommand = (
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
setText(newContent);
setSuggestion(null);
};
}

/**
* Given some text content from a node and the cursor position, search through the content
* to find a mention or a command. If there is one present, return a slice of the content
* from the special character to the end of the input
*
* @param text - the text content of a node
* @param offset - the current cursor offset position
alunturner marked this conversation as resolved.
Show resolved Hide resolved
* @returns an empty string if no mention or command is found, otherwise the mention/command substring
*/
export function findMentionOrCommand(text: string, offset: number): { text: string; startOffset: number } | null {
// return early if the offset is outside the content
if (offset < 0 || offset > text.length) {
return null;
}

let startCharIndex = offset - 1; // due to us searching left from here
let endCharIndex = offset; // due to us searching right from here

const keepMovingLeft = (): boolean => {
const specialStartChars = /[@#/]/;
const currentChar = text[startCharIndex];
const precedingChar = text[startCharIndex - 1] ?? "";

const currentCharIsSpecial = specialStartChars.test(currentChar);

const shouldIgnoreMentionChar = (currentChar === "@" || currentChar === "#") && /\S/.test(precedingChar);

return startCharIndex >= 0 && (!currentCharIsSpecial || shouldIgnoreMentionChar);
};

const keepMovingRight = (): boolean => {
const specialEndChars = /\s/;
return !specialEndChars.test(text[endCharIndex]) && endCharIndex < text.length;
};

while (keepMovingLeft()) {
// special case - if we hit some whitespace, just return null, this is to catch cases
// where user types a special character then whitespace
if (/\s/.test(text[startCharIndex])) {
return null;
}
startCharIndex--;
}

while (keepMovingRight()) {
endCharIndex++;
}

// we have looped through the first part of the string => if we get a minus one
// as the index, we haven't found a start point, so return
// alternatively if we have found command (/) and the startCharIndex isn't 0, return ""
// or also if we hav found a command and the end index isn't the end of the content
const startCharIsCommand = text[startCharIndex] === "/";
if (
startCharIndex < 0 ||
(startCharIsCommand && startCharIndex !== 0) ||
(startCharIsCommand && endCharIndex !== text.length)
) {
return null;
} else {
return { text: text.slice(startCharIndex, endCharIndex), startOffset: startCharIndex };
}
}

/**
* 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
* something that could be a command or a mention and update the suggestion state if so
*
* @param editorRef - ref to the composer
* @param suggestion - the current suggestion state
* @param setSuggestion - the setter for the suggestion state
*/
export const processSelectionChange = (
export function processSelectionChange(
editorRef: React.RefObject<HTMLDivElement>,
suggestion: SuggestionState,
setSuggestion: React.Dispatch<React.SetStateAction<SuggestionState>>,
): void => {
): void {
const selection = document.getSelection();

// return early if we do not have a current editor ref with a cursor selection inside a text node
Expand All @@ -150,43 +241,65 @@ export const processSelectionChange = (
!selection.isCollapsed ||
selection.anchorNode?.nodeName !== "#text"
) {
setSuggestion(null);
return;
}

// 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;
// here we have established that we have a cursor and both anchor and focus nodes in the selection
// are the same node, so rename to `currentNode` and `currentOffset` for subsequent use
const { anchorNode: currentNode, anchorOffset: currentOffset } = 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>
const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();

// if we're not in the first text node or we have no text content, return
if (currentNode !== firstTextNode || currentNode.textContent === null) {
// if we have no text content, return
if (currentNode.textContent === null) return;

const mentionOrCommand = findMentionOrCommand(currentNode.textContent, currentOffset);

// if we don't have any mentionsOrCommands, return, clearing the suggestion state
if (mentionOrCommand === null) {
setSuggestion(null);
return;
}

// 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);
}
// else we do have something, so get the constituent parts
const suggestionParts = getMentionOrCommandParts(mentionOrCommand.text);

// if we have a command at the beginning of a node, but that node isn't the first text node, return
if (suggestionParts.type === "command" && currentNode !== firstTextNode) {
setSuggestion(null);
return;
} else {
// else, we have found a mention or a command
setSuggestion({
keyChar: "/",
type: "command",
text: commandMatches[1],
...suggestionParts,
node: selection.anchorNode,
startOffset: 0,
endOffset: currentNode.textContent.length,
startOffset: mentionOrCommand.startOffset,
endOffset: mentionOrCommand.startOffset + mentionOrCommand.text.length,
});
}
};
}

/**
* Given a string that represents a suggestion in the composer, return an object that represents
* that text as a `MappedSuggestion`.
*
* @param suggestionText - string that could be a mention of a command type suggestion
* @returns an object representing the `MappedSuggestion` from that string
*/
export function getMentionOrCommandParts(suggestionText: string): MappedSuggestion {
const firstChar = suggestionText.charAt(0);
const restOfString = suggestionText.slice(1);

switch (firstChar) {
case "/":
return { keyChar: firstChar, text: restOfString, type: "command" };
case "#":
case "@":
return { keyChar: firstChar, text: restOfString, type: "mention" };
default:
return { keyChar: "", text: "", type: "unknown" };
}
}
Loading