Commands for plain text editor (#10567)

* add the handlers for when autocomplete is open plus rough / handling

* hack in using the wysiwyg autocomplete

* switch to using onSelect for the behaviour

* expand comment

* add a handle command function to replace text

* add event firing step

* fix TS errors for RefObject

* extract common functionality to new util

* use util for plain text mode

* use util for rich text mode

* remove unused imports

* make util able to handle either type of keyboard event

* fix TS error for mxClient

* lift all new code into main component prior to extracting to custom hook

* shift logic into custom hook

* rename ref to editorRef for clarity

* remove comment

* try to add cypress test for behaviour

* remove unused imports

* fix various lint/TS errors for CI

* update cypress test

* add test for pressing escape to close autocomplete

* expand cypress tests

* add typing while autocomplete open test

* refactor to single piece of state and update comments

* update comment

* extract functions for testing

* add first tests

* improve tests

* remove console log

* call useSuggestion hook from different location

* update useSuggestion hook tests

* improve cypress tests

* remove unused import

* fix selector in cypress test

* add another set of util tests

* remove .only

* remove .only

* remove import

* improve cypress tests

* remove .only

* add comment

* improve comments

* tidy up tests

* consolidate all cypress tests to one

* add early return

* fix typo, add documentation

* add early return, tidy up comments

* change function expression to function declaration

* add documentation

* fix broken test

* add check to cypress tests

* update types

* update comment

* update comments

* shift ref declaration inside the hook

* remove unused import

* update cypress test and add comments

* update usePlainTextListener comments

* apply suggested changes to useSuggestion

* update tests

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
alunturner 2023-04-27 08:37:47 +01:00 committed by GitHub
parent 0a22ed90ef
commit ca25c8f430
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 626 additions and 48 deletions

View file

@ -24,6 +24,7 @@ import { usePlainTextListeners } from "../hooks/usePlainTextListeners";
import { useSetCursorPosition } from "../hooks/useSetCursorPosition";
import { ComposerFunctions } from "../types";
import { Editor } from "./Editor";
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
interface PlainTextComposerProps {
disabled?: boolean;
@ -48,14 +49,23 @@ export function PlainTextComposer({
leftComponent,
rightComponent,
}: PlainTextComposerProps): JSX.Element {
const { ref, onInput, onPaste, onKeyDown, content, setContent } = usePlainTextListeners(
initialContent,
onChange,
onSend,
);
const composerFunctions = useComposerFunctions(ref, setContent);
usePlainTextInitialization(initialContent, ref);
useSetCursorPosition(disabled, ref);
const {
ref: editorRef,
autocompleteRef,
onInput,
onPaste,
onKeyDown,
content,
setContent,
suggestion,
onSelect,
handleCommand,
handleMention,
} = usePlainTextListeners(initialContent, onChange, onSend);
const composerFunctions = useComposerFunctions(editorRef, setContent);
usePlainTextInitialization(initialContent, editorRef);
useSetCursorPosition(disabled, editorRef);
const { isFocused, onFocus } = useIsFocused();
const computedPlaceholder = (!content && placeholder) || undefined;
@ -68,15 +78,22 @@ export function PlainTextComposer({
onInput={onInput}
onPaste={onPaste}
onKeyDown={onKeyDown}
onSelect={onSelect}
>
<WysiwygAutocomplete
ref={autocompleteRef}
suggestion={suggestion}
handleMention={handleMention}
handleCommand={handleCommand}
/>
<Editor
ref={ref}
ref={editorRef}
disabled={disabled}
leftComponent={leftComponent}
rightComponent={rightComponent}
placeholder={computedPlaceholder}
/>
{children?.(ref, composerFunctions)}
{children?.(editorRef, composerFunctions)}
</div>
);
}

View file

@ -33,6 +33,7 @@ import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event";
import { endEditing } from "../utils/editing";
import Autocomplete from "../../Autocomplete";
import { handleEventWithAutocomplete } from "./utils";
export function useInputEventProcessor(
onSend: () => void,
@ -91,7 +92,7 @@ function handleKeyboardEvent(
editor: HTMLElement,
roomContext: IRoomState,
composerContext: ComposerContextState,
mxClient: MatrixClient,
mxClient: MatrixClient | undefined,
autocompleteRef: React.RefObject<Autocomplete>,
): KeyboardEvent | null {
const { editorStateTransfer } = composerContext;
@ -99,42 +100,15 @@ function handleKeyboardEvent(
const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0;
const action = getKeyBindingsManager().getMessageComposerAction(event);
const autocompleteIsOpen = autocompleteRef?.current && !autocompleteRef.current.state.hide;
// we need autocomplete to take priority when it is open for using enter to select
if (autocompleteIsOpen) {
let handled = false;
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
const component = autocompleteRef.current;
if (component && component.countCompletions() > 0) {
switch (autocompleteAction) {
case KeyBindingAction.ForceCompleteAutocomplete:
case KeyBindingAction.CompleteAutocomplete:
autocompleteRef.current.onConfirmCompletion();
handled = true;
break;
case KeyBindingAction.PrevSelectionInAutocomplete:
autocompleteRef.current.moveSelection(-1);
handled = true;
break;
case KeyBindingAction.NextSelectionInAutocomplete:
autocompleteRef.current.moveSelection(1);
handled = true;
break;
case KeyBindingAction.CancelAutocomplete:
autocompleteRef.current.onEscape(event as {} as React.KeyboardEvent);
handled = true;
break;
default:
break; // don't return anything, allow event to pass through
}
}
const isHandledByAutocomplete = handleEventWithAutocomplete(autocompleteRef, event);
if (isHandledByAutocomplete) {
return event;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
return event;
}
// taking the client from context gives us an client | undefined type, narrow it down
if (mxClient === undefined) {
return null;
}
switch (action) {

View file

@ -15,9 +15,13 @@ limitations under the License.
*/
import { KeyboardEvent, RefObject, SyntheticEvent, useCallback, useRef, useState } from "react";
import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
import { useSettingValue } from "../../../../../hooks/useSettings";
import { IS_MAC, Key } from "../../../../../Keyboard";
import Autocomplete from "../../Autocomplete";
import { handleEventWithAutocomplete } from "./utils";
import { useSuggestion } from "./useSuggestion";
function isDivElement(target: EventTarget): target is HTMLDivElement {
return target instanceof HTMLDivElement;
@ -33,20 +37,44 @@ function amendInnerHtml(text: string): string {
.replace(/<\/div>/g, "");
}
/**
* React hook which generates all of the listeners and the ref to be attached to the editor.
*
* Also returns pieces of state and utility functions that are required for use in other hooks
* and by the autocomplete component.
*
* @param initialContent - the content of the editor when it is first mounted
* @param onChange - called whenever there is change in the editor content
* @param onSend - called whenever the user sends the message
* @returns
* - `ref`: a ref object which the caller must attach to the HTML `div` node for the editor
* * `autocompleteRef`: a ref object which the caller must attach to the autocomplete component
* - `content`: state representing the editor's current text content
* - `setContent`: the setter function for `content`
* - `onInput`, `onPaste`, `onKeyDown`: handlers for input, paste and keyDown events
* - the output from the {@link useSuggestion} hook
*/
export function usePlainTextListeners(
initialContent?: string,
onChange?: (content: string) => void,
onSend?: () => void,
): {
ref: RefObject<HTMLDivElement>;
autocompleteRef: React.RefObject<Autocomplete>;
content?: string;
onInput(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
onPaste(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
onKeyDown(event: KeyboardEvent<HTMLDivElement>): void;
setContent(text: string): void;
handleMention: (link: string, text: string, attributes: Attributes) => void;
handleCommand: (text: string) => void;
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null;
} {
const ref = useRef<HTMLDivElement | null>(null);
const autocompleteRef = useRef<Autocomplete | null>(null);
const [content, setContent] = useState<string | undefined>(initialContent);
const send = useCallback(() => {
if (ref.current) {
ref.current.innerHTML = "";
@ -62,6 +90,11 @@ export function usePlainTextListeners(
[onChange],
);
// For separation of concerns, the suggestion handling is kept in a separate hook but is
// nested here because we do need to be able to update the `content` state in this hook
// when a user selects a suggestion from the autocomplete menu
const { suggestion, onSelect, handleCommand, handleMention } = useSuggestion(ref, setText);
const enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
const onInput = useCallback(
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
@ -76,6 +109,13 @@ export function usePlainTextListeners(
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
// we need autocomplete to take priority when it is open for using enter to select
const isHandledByAutocomplete = handleEventWithAutocomplete(autocompleteRef, event);
if (isHandledByAutocomplete) {
return;
}
// resume regular flow
if (event.key === Key.ENTER) {
// TODO use getKeyBindingsManager().getMessageComposerAction(event) like in useInputEventProcessor
const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;
@ -95,8 +135,20 @@ export function usePlainTextListeners(
}
}
},
[enterShouldSend, send],
[autocompleteRef, enterShouldSend, send],
);
return { ref, onInput, onPaste: onInput, onKeyDown, content, setContent: setText };
return {
ref,
autocompleteRef,
onInput,
onPaste: onInput,
onKeyDown,
content,
setContent: setText,
suggestion,
onSelect,
handleCommand,
handleMention,
};
}

View file

@ -0,0 +1,192 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
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.
*/
node: Node;
startOffset: number;
endOffset: number;
};
type SuggestionState = Suggestion | null;
/**
* React hook to allow tracking and replacing of mentions and commands in a div element
*
* @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
* - `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;
handleCommand: (text: string) => void;
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null;
} {
const [suggestion, setSuggestion] = 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 can not depend on input events only
const onSelect = (): void => processSelectionChange(editorRef, suggestion, setSuggestion);
const handleCommand = (replacementText: string): void =>
processCommand(replacementText, suggestion, setSuggestion, setText);
return {
suggestion: mapSuggestion(suggestion),
handleCommand,
handleMention,
onSelect,
};
}
/**
* Convert a PlainTextSuggestionPattern (or null) to a MappedSuggestion (or null)
*
* @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
*/
export const mapSuggestion = (suggestion: SuggestionState): MappedSuggestion | null => {
if (suggestion === null) {
return null;
} else {
const { node, startOffset, endOffset, ...mappedSuggestion } = suggestion;
return mappedSuggestion;
}
};
/**
* 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 setText - setter function to set the content of the composer
*/
export const processCommand = (
replacementText: string,
suggestion: SuggestionState,
setSuggestion: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text: string) => void,
): void => {
// if we do not have a suggestion, return early
if (suggestion === null) {
return;
}
const { node } = suggestion;
// 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
const newContent = `${replacementText} `;
node.textContent = newContent;
// then set the cursor to the end of the node, update the `content` state in the usePlainTextListeners
// hook and clear the suggestion from state
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
setText(newContent);
setSuggestion(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
*
* @param editorRef - ref to the composer
* @param suggestion - the current suggestion state
* @param setSuggestion - the setter for the suggestion state
*/
export const processSelectionChange = (
editorRef: React.RefObject<HTMLDivElement>,
suggestion: SuggestionState,
setSuggestion: 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"
) {
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;
// 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) {
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);
}
return;
} else {
setSuggestion({
keyChar: "/",
type: "command",
text: commandMatches[1],
node: selection.anchorNode,
startOffset: 0,
endOffset: currentNode.textContent.length,
});
}
};

View file

@ -14,10 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MutableRefObject } from "react";
import { MutableRefObject, RefObject } from "react";
import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
import { IRoomState } from "../../../../structures/RoomView";
import Autocomplete from "../../Autocomplete";
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
export function focusComposer(
composerElement: MutableRefObject<HTMLElement | null>,
@ -51,3 +54,59 @@ export function setCursorPositionAtTheEnd(element: HTMLElement): void {
element.focus();
}
/**
* When the autocomplete modal is open we need to be able to properly
* handle events that are dispatched. This allows the user to move the selection
* in the autocomplete and select using enter.
*
* @param autocompleteRef - a ref to the autocomplete of interest
* @param event - the keyboard event that has been dispatched
* @returns boolean - whether or not the autocomplete has handled the event
*/
export function handleEventWithAutocomplete(
autocompleteRef: RefObject<Autocomplete>,
// we get a React Keyboard event from plain text composer, a Keyboard Event from the rich text composer
event: KeyboardEvent | React.KeyboardEvent<HTMLDivElement>,
): boolean {
const autocompleteIsOpen = autocompleteRef?.current && !autocompleteRef.current.state.hide;
if (!autocompleteIsOpen) {
return false;
}
let handled = false;
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
const component = autocompleteRef.current;
if (component && component.countCompletions() > 0) {
switch (autocompleteAction) {
case KeyBindingAction.ForceCompleteAutocomplete:
case KeyBindingAction.CompleteAutocomplete:
autocompleteRef.current.onConfirmCompletion();
handled = true;
break;
case KeyBindingAction.PrevSelectionInAutocomplete:
autocompleteRef.current.moveSelection(-1);
handled = true;
break;
case KeyBindingAction.NextSelectionInAutocomplete:
autocompleteRef.current.moveSelection(1);
handled = true;
break;
case KeyBindingAction.CancelAutocomplete:
autocompleteRef.current.onEscape(event as {} as React.KeyboardEvent);
handled = true;
break;
default:
break; // don't return anything, allow event to pass through
}
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
return handled;
}