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:
parent
acdbae3e8c
commit
0d981326ac
6 changed files with 146 additions and 40 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue