RTE plain text mentions as pills (#10852)

* insert mentions as links styled as pills

* post merge fix and update test

* update comments, move typeguard out

* create a text node instead of setting innerText

* update test

* update test

* fix broken cypress test, remove .only

* make it able to deal with inserting in middle of blank lines

* update comment

* fix strict null error

* use typeguard

* avoid implicit truth check

* add hook tests

* add comment

* Update test/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners-test.tsx

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>

---------

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
This commit is contained in:
alunturner 2023-05-16 12:54:16 +01:00 committed by GitHub
parent acdbae3e8c
commit 0d981326ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 146 additions and 40 deletions

View file

@ -17,3 +17,7 @@ limitations under the License.
export function isNotNull<T>(arg: T): arg is Exclude<T, null> {
return arg !== null;
}
export function isNotUndefined<T>(arg: T): arg is Exclude<T, undefined> {
return arg !== undefined;
}

View file

@ -22,6 +22,7 @@ import { IS_MAC, Key } from "../../../../../Keyboard";
import Autocomplete from "../../Autocomplete";
import { handleEventWithAutocomplete } from "./utils";
import { useSuggestion } from "./useSuggestion";
import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
function isDivElement(target: EventTarget): target is HTMLDivElement {
return target instanceof HTMLDivElement;
@ -65,7 +66,7 @@ export function usePlainTextListeners(
onInput(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
onPaste(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
onKeyDown(event: KeyboardEvent<HTMLDivElement>): void;
setContent(text: string): void;
setContent(text?: string): void;
handleMention: (link: string, text: string, attributes: Attributes) => void;
handleCommand: (text: string) => void;
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
@ -83,11 +84,18 @@ export function usePlainTextListeners(
}, [ref, onSend]);
const setText = useCallback(
(text: string) => {
setContent(text);
onChange?.(text);
(text?: string) => {
if (isNotUndefined(text)) {
setContent(text);
onChange?.(text);
} else if (isNotNull(ref) && isNotNull(ref.current)) {
// if called with no argument, read the current innerHTML from the ref
const currentRefContent = ref.current.innerHTML;
setContent(currentRefContent);
onChange?.(currentRefContent);
}
},
[onChange],
[onChange, ref],
);
// For separation of concerns, the suggestion handling is kept in a separate hook but is

View file

@ -17,6 +17,8 @@ limitations under the License.
import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
import { SyntheticEvent, useState } from "react";
import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
/**
* Information about the current state of the `useSuggestion` hook.
*/
@ -49,7 +51,7 @@ type SuggestionState = Suggestion | null;
*/
export function useSuggestion(
editorRef: React.RefObject<HTMLDivElement>,
setText: (text: string) => void,
setText: (text?: string) => void,
): {
handleMention: (href: string, displayName: string, attributes: Attributes) => void;
handleCommand: (text: string) => void;
@ -144,7 +146,7 @@ export function processMention(
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,
setText: (text?: string) => void,
): void {
// if we do not have a suggestion, return early
if (suggestionData === null) {
@ -153,18 +155,34 @@ export function processMention(
const { node } = suggestionData;
const textBeforeReplacement = node.textContent?.slice(0, suggestionData.startOffset) ?? "";
const textAfterReplacement = node.textContent?.slice(suggestionData.endOffset) ?? "";
// create an <a> element with the required attributes to allow us to interpret the mention as being a pill
const linkElement = document.createElement("a");
const linkTextNode = document.createTextNode(displayName);
linkElement.setAttribute("href", href);
linkElement.setAttribute("contenteditable", "false");
Object.entries(attributes).forEach(
([attr, value]) => isNotUndefined(value) && linkElement.setAttribute(attr, value),
);
linkElement.appendChild(linkTextNode);
// 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;
// create text nodes to go before and after the link
const leadingTextNode = document.createTextNode(node.textContent?.slice(0, suggestionData.startOffset) || "\u200b");
const trailingTextNode = document.createTextNode(` ${node.textContent?.slice(suggestionData.endOffset) ?? ""}`);
// 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);
// now add the leading text node, link element and trailing text node before removing the node we are replacing
const parentNode = node.parentNode;
if (isNotNull(parentNode)) {
parentNode.insertBefore(leadingTextNode, node);
parentNode.insertBefore(linkElement, node);
parentNode.insertBefore(trailingTextNode, node);
parentNode.removeChild(node);
}
// move the selection to the trailing text node
document.getSelection()?.setBaseAndExtent(trailingTextNode, 1, trailingTextNode, 1);
// set the text content to be the innerHTML of the current editor ref and clear the suggestion state
setText();
setSuggestionData(null);
}
@ -181,7 +199,7 @@ export function processCommand(
replacementText: string,
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text: string) => void,
setText: (text?: string) => void,
): void {
// if we do not have a suggestion, return early
if (suggestionData === null) {