Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Commands for plain text editor (#10567)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
alunturner and t3chguy committed Apr 27, 2023
1 parent 0a22ed9 commit ca25c8f
Show file tree
Hide file tree
Showing 9 changed files with 626 additions and 48 deletions.
64 changes: 64 additions & 0 deletions cypress/e2e/composer/composer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,70 @@ describe("Composer", () => {
cy.viewRoomByName("Composing Room");
});

describe("Commands", () => {
// TODO add tests for rich text mode

describe("Plain text mode", () => {
it("autocomplete behaviour tests", () => {
// 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 / displays the autocomplete menu and contents
cy.findByRole("textbox").type("/");

// Check that the autocomplete options are visible and there are more than 0 items
cy.findByTestId("autocomplete-wrapper").should("not.be.empty");

// Entering `//` or `/ ` hides the autocomplete contents
// Add an extra slash for `//`
cy.findByRole("textbox").type("/");
cy.findByTestId("autocomplete-wrapper").should("be.empty");
// Remove the extra slash to go back to `/`
cy.findByRole("textbox").type("{Backspace}");
cy.findByTestId("autocomplete-wrapper").should("not.be.empty");
// Add a trailing space for `/ `
cy.findByRole("textbox").type(" ");
cy.findByTestId("autocomplete-wrapper").should("be.empty");

// Typing a command that takes no arguments (/devtools) and selecting by click works
cy.findByRole("textbox").type("{Backspace}dev");
cy.findByTestId("autocomplete-wrapper").within(() => {
cy.findByText("/devtools").click();
});
// Check it has closed the autocomplete and put the text into the composer
cy.findByTestId("autocomplete-wrapper").should("not.be.visible");
cy.findByRole("textbox").within(() => {
cy.findByText("/devtools").should("exist");
});
// Send the message and check the devtools dialog appeared, then close it
cy.findByRole("button", { name: "Send message" }).click();
cy.findByRole("dialog").within(() => {
cy.findByText("Developer Tools").should("exist");
});
cy.findByRole("button", { name: "Close dialog" }).click();

// Typing a command that takes arguments (/spoiler) and selecting with enter works
cy.findByRole("textbox").type("/spoil");
cy.findByTestId("autocomplete-wrapper").within(() => {
cy.findByText("/spoiler").should("exist");
});
cy.findByRole("textbox").type("{Enter}");
// Check it has closed the autocomplete and put the text into the composer
cy.findByTestId("autocomplete-wrapper").should("not.be.visible");
cy.findByRole("textbox").within(() => {
cy.findByText("/spoiler").should("exist");
});
// Enter some more text, then send the message
cy.findByRole("textbox").type("this is the spoiler text ");
cy.findByRole("button", { name: "Send message" }).click();
// Check that a spoiler item has appeared in the timeline and contains the spoiler command text
cy.get("span.mx_EventTile_spoiler").should("exist");
cy.findByText("this is the spoiler text").should("exist");
});
});
});

it("sends a message when you click send or press Enter", () => {
// Type a message
cy.get("div[contenteditable=true]").type("my message 0");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -91,50 +92,23 @@ function handleKeyboardEvent(
editor: HTMLElement,
roomContext: IRoomState,
composerContext: ComposerContextState,
mxClient: MatrixClient,
mxClient: MatrixClient | undefined,
autocompleteRef: React.RefObject<Autocomplete>,
): KeyboardEvent | null {
const { editorStateTransfer } = composerContext;
const isEditing = Boolean(editorStateTransfer);
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = "";
Expand All @@ -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>) => {
Expand All @@ -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;
Expand All @@ -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,
};
}
Loading

0 comments on commit ca25c8f

Please sign in to comment.