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

Commit ca25c8f

Browse files
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 <[email protected]>
1 parent 0a22ed9 commit ca25c8f

File tree

9 files changed

+626
-48
lines changed

9 files changed

+626
-48
lines changed

cypress/e2e/composer/composer.spec.ts

+64
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,70 @@ describe("Composer", () => {
117117
cy.viewRoomByName("Composing Room");
118118
});
119119

120+
describe("Commands", () => {
121+
// TODO add tests for rich text mode
122+
123+
describe("Plain text mode", () => {
124+
it("autocomplete behaviour tests", () => {
125+
// Select plain text mode after composer is ready
126+
cy.get("div[contenteditable=true]").should("exist");
127+
cy.findByRole("button", { name: "Hide formatting" }).click();
128+
129+
// Typing a single / displays the autocomplete menu and contents
130+
cy.findByRole("textbox").type("/");
131+
132+
// Check that the autocomplete options are visible and there are more than 0 items
133+
cy.findByTestId("autocomplete-wrapper").should("not.be.empty");
134+
135+
// Entering `//` or `/ ` hides the autocomplete contents
136+
// Add an extra slash for `//`
137+
cy.findByRole("textbox").type("/");
138+
cy.findByTestId("autocomplete-wrapper").should("be.empty");
139+
// Remove the extra slash to go back to `/`
140+
cy.findByRole("textbox").type("{Backspace}");
141+
cy.findByTestId("autocomplete-wrapper").should("not.be.empty");
142+
// Add a trailing space for `/ `
143+
cy.findByRole("textbox").type(" ");
144+
cy.findByTestId("autocomplete-wrapper").should("be.empty");
145+
146+
// Typing a command that takes no arguments (/devtools) and selecting by click works
147+
cy.findByRole("textbox").type("{Backspace}dev");
148+
cy.findByTestId("autocomplete-wrapper").within(() => {
149+
cy.findByText("/devtools").click();
150+
});
151+
// Check it has closed the autocomplete and put the text into the composer
152+
cy.findByTestId("autocomplete-wrapper").should("not.be.visible");
153+
cy.findByRole("textbox").within(() => {
154+
cy.findByText("/devtools").should("exist");
155+
});
156+
// Send the message and check the devtools dialog appeared, then close it
157+
cy.findByRole("button", { name: "Send message" }).click();
158+
cy.findByRole("dialog").within(() => {
159+
cy.findByText("Developer Tools").should("exist");
160+
});
161+
cy.findByRole("button", { name: "Close dialog" }).click();
162+
163+
// Typing a command that takes arguments (/spoiler) and selecting with enter works
164+
cy.findByRole("textbox").type("/spoil");
165+
cy.findByTestId("autocomplete-wrapper").within(() => {
166+
cy.findByText("/spoiler").should("exist");
167+
});
168+
cy.findByRole("textbox").type("{Enter}");
169+
// Check it has closed the autocomplete and put the text into the composer
170+
cy.findByTestId("autocomplete-wrapper").should("not.be.visible");
171+
cy.findByRole("textbox").within(() => {
172+
cy.findByText("/spoiler").should("exist");
173+
});
174+
// Enter some more text, then send the message
175+
cy.findByRole("textbox").type("this is the spoiler text ");
176+
cy.findByRole("button", { name: "Send message" }).click();
177+
// Check that a spoiler item has appeared in the timeline and contains the spoiler command text
178+
cy.get("span.mx_EventTile_spoiler").should("exist");
179+
cy.findByText("this is the spoiler text").should("exist");
180+
});
181+
});
182+
});
183+
120184
it("sends a message when you click send or press Enter", () => {
121185
// Type a message
122186
cy.get("div[contenteditable=true]").type("my message 0");

src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx

+27-10
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { usePlainTextListeners } from "../hooks/usePlainTextListeners";
2424
import { useSetCursorPosition } from "../hooks/useSetCursorPosition";
2525
import { ComposerFunctions } from "../types";
2626
import { Editor } from "./Editor";
27+
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
2728

2829
interface PlainTextComposerProps {
2930
disabled?: boolean;
@@ -48,14 +49,23 @@ export function PlainTextComposer({
4849
leftComponent,
4950
rightComponent,
5051
}: PlainTextComposerProps): JSX.Element {
51-
const { ref, onInput, onPaste, onKeyDown, content, setContent } = usePlainTextListeners(
52-
initialContent,
53-
onChange,
54-
onSend,
55-
);
56-
const composerFunctions = useComposerFunctions(ref, setContent);
57-
usePlainTextInitialization(initialContent, ref);
58-
useSetCursorPosition(disabled, ref);
52+
const {
53+
ref: editorRef,
54+
autocompleteRef,
55+
onInput,
56+
onPaste,
57+
onKeyDown,
58+
content,
59+
setContent,
60+
suggestion,
61+
onSelect,
62+
handleCommand,
63+
handleMention,
64+
} = usePlainTextListeners(initialContent, onChange, onSend);
65+
66+
const composerFunctions = useComposerFunctions(editorRef, setContent);
67+
usePlainTextInitialization(initialContent, editorRef);
68+
useSetCursorPosition(disabled, editorRef);
5969
const { isFocused, onFocus } = useIsFocused();
6070
const computedPlaceholder = (!content && placeholder) || undefined;
6171

@@ -68,15 +78,22 @@ export function PlainTextComposer({
6878
onInput={onInput}
6979
onPaste={onPaste}
7080
onKeyDown={onKeyDown}
81+
onSelect={onSelect}
7182
>
83+
<WysiwygAutocomplete
84+
ref={autocompleteRef}
85+
suggestion={suggestion}
86+
handleMention={handleMention}
87+
handleCommand={handleCommand}
88+
/>
7289
<Editor
73-
ref={ref}
90+
ref={editorRef}
7491
disabled={disabled}
7592
leftComponent={leftComponent}
7693
rightComponent={rightComponent}
7794
placeholder={computedPlaceholder}
7895
/>
79-
{children?.(ref, composerFunctions)}
96+
{children?.(editorRef, composerFunctions)}
8097
</div>
8198
);
8299
}

src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts

+9-35
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
3333
import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event";
3434
import { endEditing } from "../utils/editing";
3535
import Autocomplete from "../../Autocomplete";
36+
import { handleEventWithAutocomplete } from "./utils";
3637

3738
export function useInputEventProcessor(
3839
onSend: () => void,
@@ -91,50 +92,23 @@ function handleKeyboardEvent(
9192
editor: HTMLElement,
9293
roomContext: IRoomState,
9394
composerContext: ComposerContextState,
94-
mxClient: MatrixClient,
95+
mxClient: MatrixClient | undefined,
9596
autocompleteRef: React.RefObject<Autocomplete>,
9697
): KeyboardEvent | null {
9798
const { editorStateTransfer } = composerContext;
9899
const isEditing = Boolean(editorStateTransfer);
99100
const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0;
100101
const action = getKeyBindingsManager().getMessageComposerAction(event);
101102

102-
const autocompleteIsOpen = autocompleteRef?.current && !autocompleteRef.current.state.hide;
103-
104103
// we need autocomplete to take priority when it is open for using enter to select
105-
if (autocompleteIsOpen) {
106-
let handled = false;
107-
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
108-
const component = autocompleteRef.current;
109-
if (component && component.countCompletions() > 0) {
110-
switch (autocompleteAction) {
111-
case KeyBindingAction.ForceCompleteAutocomplete:
112-
case KeyBindingAction.CompleteAutocomplete:
113-
autocompleteRef.current.onConfirmCompletion();
114-
handled = true;
115-
break;
116-
case KeyBindingAction.PrevSelectionInAutocomplete:
117-
autocompleteRef.current.moveSelection(-1);
118-
handled = true;
119-
break;
120-
case KeyBindingAction.NextSelectionInAutocomplete:
121-
autocompleteRef.current.moveSelection(1);
122-
handled = true;
123-
break;
124-
case KeyBindingAction.CancelAutocomplete:
125-
autocompleteRef.current.onEscape(event as {} as React.KeyboardEvent);
126-
handled = true;
127-
break;
128-
default:
129-
break; // don't return anything, allow event to pass through
130-
}
131-
}
104+
const isHandledByAutocomplete = handleEventWithAutocomplete(autocompleteRef, event);
105+
if (isHandledByAutocomplete) {
106+
return event;
107+
}
132108

133-
if (handled) {
134-
event.preventDefault();
135-
event.stopPropagation();
136-
return event;
137-
}
109+
// taking the client from context gives us an client | undefined type, narrow it down
110+
if (mxClient === undefined) {
111+
return null;
138112
}
139113

140114
switch (action) {

src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts

+54-2
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@ limitations under the License.
1515
*/
1616

1717
import { KeyboardEvent, RefObject, SyntheticEvent, useCallback, useRef, useState } from "react";
18+
import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
1819

1920
import { useSettingValue } from "../../../../../hooks/useSettings";
2021
import { IS_MAC, Key } from "../../../../../Keyboard";
22+
import Autocomplete from "../../Autocomplete";
23+
import { handleEventWithAutocomplete } from "./utils";
24+
import { useSuggestion } from "./useSuggestion";
2125

2226
function isDivElement(target: EventTarget): target is HTMLDivElement {
2327
return target instanceof HTMLDivElement;
@@ -33,20 +37,44 @@ function amendInnerHtml(text: string): string {
3337
.replace(/<\/div>/g, "");
3438
}
3539

40+
/**
41+
* React hook which generates all of the listeners and the ref to be attached to the editor.
42+
*
43+
* Also returns pieces of state and utility functions that are required for use in other hooks
44+
* and by the autocomplete component.
45+
*
46+
* @param initialContent - the content of the editor when it is first mounted
47+
* @param onChange - called whenever there is change in the editor content
48+
* @param onSend - called whenever the user sends the message
49+
* @returns
50+
* - `ref`: a ref object which the caller must attach to the HTML `div` node for the editor
51+
* * `autocompleteRef`: a ref object which the caller must attach to the autocomplete component
52+
* - `content`: state representing the editor's current text content
53+
* - `setContent`: the setter function for `content`
54+
* - `onInput`, `onPaste`, `onKeyDown`: handlers for input, paste and keyDown events
55+
* - the output from the {@link useSuggestion} hook
56+
*/
3657
export function usePlainTextListeners(
3758
initialContent?: string,
3859
onChange?: (content: string) => void,
3960
onSend?: () => void,
4061
): {
4162
ref: RefObject<HTMLDivElement>;
63+
autocompleteRef: React.RefObject<Autocomplete>;
4264
content?: string;
4365
onInput(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
4466
onPaste(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
4567
onKeyDown(event: KeyboardEvent<HTMLDivElement>): void;
4668
setContent(text: string): void;
69+
handleMention: (link: string, text: string, attributes: Attributes) => void;
70+
handleCommand: (text: string) => void;
71+
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
72+
suggestion: MappedSuggestion | null;
4773
} {
4874
const ref = useRef<HTMLDivElement | null>(null);
75+
const autocompleteRef = useRef<Autocomplete | null>(null);
4976
const [content, setContent] = useState<string | undefined>(initialContent);
77+
5078
const send = useCallback(() => {
5179
if (ref.current) {
5280
ref.current.innerHTML = "";
@@ -62,6 +90,11 @@ export function usePlainTextListeners(
6290
[onChange],
6391
);
6492

93+
// For separation of concerns, the suggestion handling is kept in a separate hook but is
94+
// nested here because we do need to be able to update the `content` state in this hook
95+
// when a user selects a suggestion from the autocomplete menu
96+
const { suggestion, onSelect, handleCommand, handleMention } = useSuggestion(ref, setText);
97+
6598
const enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
6699
const onInput = useCallback(
67100
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
@@ -76,6 +109,13 @@ export function usePlainTextListeners(
76109

77110
const onKeyDown = useCallback(
78111
(event: KeyboardEvent<HTMLDivElement>) => {
112+
// we need autocomplete to take priority when it is open for using enter to select
113+
const isHandledByAutocomplete = handleEventWithAutocomplete(autocompleteRef, event);
114+
if (isHandledByAutocomplete) {
115+
return;
116+
}
117+
118+
// resume regular flow
79119
if (event.key === Key.ENTER) {
80120
// TODO use getKeyBindingsManager().getMessageComposerAction(event) like in useInputEventProcessor
81121
const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;
@@ -95,8 +135,20 @@ export function usePlainTextListeners(
95135
}
96136
}
97137
},
98-
[enterShouldSend, send],
138+
[autocompleteRef, enterShouldSend, send],
99139
);
100140

101-
return { ref, onInput, onPaste: onInput, onKeyDown, content, setContent: setText };
141+
return {
142+
ref,
143+
autocompleteRef,
144+
onInput,
145+
onPaste: onInput,
146+
onKeyDown,
147+
content,
148+
setContent: setText,
149+
suggestion,
150+
onSelect,
151+
handleCommand,
152+
handleMention,
153+
};
102154
}

0 commit comments

Comments
 (0)