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

Commands for plain text editor #10567

Merged
merged 76 commits into from
Apr 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
b305cc9
add the handlers for when autocomplete is open plus rough / handling
Apr 11, 2023
f092fb1
hack in using the wysiwyg autocomplete
Apr 11, 2023
86883f8
switch to using onSelect for the behaviour
Apr 11, 2023
d300050
expand comment
Apr 11, 2023
092fd71
add a handle command function to replace text
Apr 11, 2023
9f2e7f1
add event firing step
Apr 11, 2023
e29b2e4
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 11, 2023
26b995d
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 13, 2023
47d0d53
fix TS errors for RefObject
Apr 13, 2023
ac776b4
extract common functionality to new util
Apr 13, 2023
7de5f45
use util for plain text mode
Apr 13, 2023
5f89098
use util for rich text mode
Apr 13, 2023
ba8999c
remove unused imports
Apr 13, 2023
46f5309
make util able to handle either type of keyboard event
Apr 13, 2023
f857c14
fix TS error for mxClient
Apr 13, 2023
26baf97
lift all new code into main component prior to extracting to custom hook
Apr 13, 2023
380e265
shift logic into custom hook
Apr 13, 2023
e0fa7dd
rename ref to editorRef for clarity
Apr 13, 2023
76f46b4
remove comment
Apr 13, 2023
5d971b3
try to add cypress test for behaviour
Apr 14, 2023
14fd02b
remove unused imports
Apr 14, 2023
e08f450
fix various lint/TS errors for CI
Apr 14, 2023
6a40aa6
update cypress test
Apr 14, 2023
69a6ca4
add test for pressing escape to close autocomplete
Apr 14, 2023
c092b3e
expand cypress tests
Apr 14, 2023
262c7c0
add typing while autocomplete open test
Apr 14, 2023
3e845e4
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 14, 2023
590650e
refactor to single piece of state and update comments
Apr 14, 2023
7ac2318
update comment
Apr 14, 2023
1d03122
extract functions for testing
Apr 14, 2023
cc357ae
add first tests
Apr 14, 2023
aac6630
improve tests
Apr 18, 2023
80108c7
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 18, 2023
7e9a230
remove console log
Apr 18, 2023
2eb1203
call useSuggestion hook from different location
Apr 18, 2023
3ad7c45
update useSuggestion hook tests
Apr 18, 2023
9db2a45
improve cypress tests
Apr 18, 2023
b55ba4f
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 18, 2023
f83d074
remove unused import
Apr 18, 2023
368ce13
fix selector in cypress test
Apr 18, 2023
449521a
add another set of util tests
Apr 18, 2023
c29d945
remove .only
Apr 18, 2023
db43084
remove .only
Apr 18, 2023
138b40e
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 18, 2023
c7d653e
remove import
Apr 18, 2023
b52eeb2
improve cypress tests
Apr 18, 2023
3198acb
remove .only
Apr 18, 2023
6647bb5
add comment
Apr 18, 2023
750dea4
improve comments
Apr 18, 2023
a467a2b
tidy up tests
Apr 18, 2023
c934f9a
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 18, 2023
8eb1d6a
Merge branch 'develop' into alunturner/mentions-for-plain-text-editor
alunturner Apr 18, 2023
aeeca8c
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 20, 2023
b3ca730
consolidate all cypress tests to one
Apr 20, 2023
cc17e0e
add early return
Apr 20, 2023
d883727
fix typo, add documentation
Apr 20, 2023
1a627ed
add early return, tidy up comments
Apr 20, 2023
bec06f9
change function expression to function declaration
Apr 20, 2023
6fdee58
add documentation
Apr 20, 2023
7948fa7
fix broken test
Apr 20, 2023
26663b5
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 21, 2023
799f9a2
add check to cypress tests
Apr 21, 2023
eeb3c6c
update types
Apr 21, 2023
ab5abb4
update comment
Apr 21, 2023
9b8ee00
update comments
Apr 21, 2023
88840bf
shift ref declaration inside the hook
Apr 21, 2023
ed1b78c
remove unused import
Apr 21, 2023
d0d2f4d
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 25, 2023
fe01f99
update cypress test and add comments
Apr 25, 2023
0d1852c
update usePlainTextListener comments
Apr 25, 2023
cbcb170
apply suggested changes to useSuggestion
Apr 25, 2023
6f3b7f0
update tests
Apr 25, 2023
66871b6
Merge remote-tracking branch 'origin/develop' into alunturner/mention…
Apr 25, 2023
da6ae51
Merge branch 'develop' into alunturner/mentions-for-plain-text-editor
Apr 25, 2023
43828d5
Merge branch 'develop' into alunturner/mentions-for-plain-text-editor
t3chguy Apr 26, 2023
3c374e0
Merge branch 'develop' into alunturner/mentions-for-plain-text-editor
alunturner Apr 27, 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
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();
alunturner marked this conversation as resolved.
Show resolved Hide resolved

// 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;
}
Comment on lines -102 to -137
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This chunk was going to have to be reused in the plain text composer, so it has not been deleted, just shifted to it's own file for reuse.

// 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(
alunturner marked this conversation as resolved.
Show resolved Hide resolved
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