mirror of https://github.com/vector-im/riot-web
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>pull/28217/head
parent
0a22ed90ef
commit
ca25c8f430
|
@ -117,6 +117,70 @@ describe("Composer", () => {
|
||||||
cy.viewRoomByName("Composing Room");
|
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", () => {
|
it("sends a message when you click send or press Enter", () => {
|
||||||
// Type a message
|
// Type a message
|
||||||
cy.get("div[contenteditable=true]").type("my message 0");
|
cy.get("div[contenteditable=true]").type("my message 0");
|
||||||
|
|
|
@ -24,6 +24,7 @@ import { usePlainTextListeners } from "../hooks/usePlainTextListeners";
|
||||||
import { useSetCursorPosition } from "../hooks/useSetCursorPosition";
|
import { useSetCursorPosition } from "../hooks/useSetCursorPosition";
|
||||||
import { ComposerFunctions } from "../types";
|
import { ComposerFunctions } from "../types";
|
||||||
import { Editor } from "./Editor";
|
import { Editor } from "./Editor";
|
||||||
|
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
|
||||||
|
|
||||||
interface PlainTextComposerProps {
|
interface PlainTextComposerProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -48,14 +49,23 @@ export function PlainTextComposer({
|
||||||
leftComponent,
|
leftComponent,
|
||||||
rightComponent,
|
rightComponent,
|
||||||
}: PlainTextComposerProps): JSX.Element {
|
}: PlainTextComposerProps): JSX.Element {
|
||||||
const { ref, onInput, onPaste, onKeyDown, content, setContent } = usePlainTextListeners(
|
const {
|
||||||
initialContent,
|
ref: editorRef,
|
||||||
onChange,
|
autocompleteRef,
|
||||||
onSend,
|
onInput,
|
||||||
);
|
onPaste,
|
||||||
const composerFunctions = useComposerFunctions(ref, setContent);
|
onKeyDown,
|
||||||
usePlainTextInitialization(initialContent, ref);
|
content,
|
||||||
useSetCursorPosition(disabled, ref);
|
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 { isFocused, onFocus } = useIsFocused();
|
||||||
const computedPlaceholder = (!content && placeholder) || undefined;
|
const computedPlaceholder = (!content && placeholder) || undefined;
|
||||||
|
|
||||||
|
@ -68,15 +78,22 @@ export function PlainTextComposer({
|
||||||
onInput={onInput}
|
onInput={onInput}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
onSelect={onSelect}
|
||||||
>
|
>
|
||||||
|
<WysiwygAutocomplete
|
||||||
|
ref={autocompleteRef}
|
||||||
|
suggestion={suggestion}
|
||||||
|
handleMention={handleMention}
|
||||||
|
handleCommand={handleCommand}
|
||||||
|
/>
|
||||||
<Editor
|
<Editor
|
||||||
ref={ref}
|
ref={editorRef}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
leftComponent={leftComponent}
|
leftComponent={leftComponent}
|
||||||
rightComponent={rightComponent}
|
rightComponent={rightComponent}
|
||||||
placeholder={computedPlaceholder}
|
placeholder={computedPlaceholder}
|
||||||
/>
|
/>
|
||||||
{children?.(ref, composerFunctions)}
|
{children?.(editorRef, composerFunctions)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
|
||||||
import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event";
|
import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event";
|
||||||
import { endEditing } from "../utils/editing";
|
import { endEditing } from "../utils/editing";
|
||||||
import Autocomplete from "../../Autocomplete";
|
import Autocomplete from "../../Autocomplete";
|
||||||
|
import { handleEventWithAutocomplete } from "./utils";
|
||||||
|
|
||||||
export function useInputEventProcessor(
|
export function useInputEventProcessor(
|
||||||
onSend: () => void,
|
onSend: () => void,
|
||||||
|
@ -91,7 +92,7 @@ function handleKeyboardEvent(
|
||||||
editor: HTMLElement,
|
editor: HTMLElement,
|
||||||
roomContext: IRoomState,
|
roomContext: IRoomState,
|
||||||
composerContext: ComposerContextState,
|
composerContext: ComposerContextState,
|
||||||
mxClient: MatrixClient,
|
mxClient: MatrixClient | undefined,
|
||||||
autocompleteRef: React.RefObject<Autocomplete>,
|
autocompleteRef: React.RefObject<Autocomplete>,
|
||||||
): KeyboardEvent | null {
|
): KeyboardEvent | null {
|
||||||
const { editorStateTransfer } = composerContext;
|
const { editorStateTransfer } = composerContext;
|
||||||
|
@ -99,42 +100,15 @@ function handleKeyboardEvent(
|
||||||
const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0;
|
const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0;
|
||||||
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
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
|
// we need autocomplete to take priority when it is open for using enter to select
|
||||||
if (autocompleteIsOpen) {
|
const isHandledByAutocomplete = handleEventWithAutocomplete(autocompleteRef, event);
|
||||||
let handled = false;
|
if (isHandledByAutocomplete) {
|
||||||
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
|
return 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) {
|
// taking the client from context gives us an client | undefined type, narrow it down
|
||||||
event.preventDefault();
|
if (mxClient === undefined) {
|
||||||
event.stopPropagation();
|
return null;
|
||||||
return event;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
|
|
@ -15,9 +15,13 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { KeyboardEvent, RefObject, SyntheticEvent, useCallback, useRef, useState } from "react";
|
import { KeyboardEvent, RefObject, SyntheticEvent, useCallback, useRef, useState } from "react";
|
||||||
|
import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
|
||||||
|
|
||||||
import { useSettingValue } from "../../../../../hooks/useSettings";
|
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||||
import { IS_MAC, Key } from "../../../../../Keyboard";
|
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 {
|
function isDivElement(target: EventTarget): target is HTMLDivElement {
|
||||||
return target instanceof HTMLDivElement;
|
return target instanceof HTMLDivElement;
|
||||||
|
@ -33,20 +37,44 @@ function amendInnerHtml(text: string): string {
|
||||||
.replace(/<\/div>/g, "");
|
.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(
|
export function usePlainTextListeners(
|
||||||
initialContent?: string,
|
initialContent?: string,
|
||||||
onChange?: (content: string) => void,
|
onChange?: (content: string) => void,
|
||||||
onSend?: () => void,
|
onSend?: () => void,
|
||||||
): {
|
): {
|
||||||
ref: RefObject<HTMLDivElement>;
|
ref: RefObject<HTMLDivElement>;
|
||||||
|
autocompleteRef: React.RefObject<Autocomplete>;
|
||||||
content?: string;
|
content?: string;
|
||||||
onInput(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
|
onInput(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
|
||||||
onPaste(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
|
onPaste(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>): void;
|
||||||
onKeyDown(event: KeyboardEvent<HTMLDivElement>): 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;
|
||||||
|
suggestion: MappedSuggestion | null;
|
||||||
} {
|
} {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const autocompleteRef = useRef<Autocomplete | null>(null);
|
||||||
const [content, setContent] = useState<string | undefined>(initialContent);
|
const [content, setContent] = useState<string | undefined>(initialContent);
|
||||||
|
|
||||||
const send = useCallback(() => {
|
const send = useCallback(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
ref.current.innerHTML = "";
|
ref.current.innerHTML = "";
|
||||||
|
@ -62,6 +90,11 @@ export function usePlainTextListeners(
|
||||||
[onChange],
|
[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 enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
|
||||||
const onInput = useCallback(
|
const onInput = useCallback(
|
||||||
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
|
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
|
||||||
|
@ -76,6 +109,13 @@ export function usePlainTextListeners(
|
||||||
|
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
(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) {
|
if (event.key === Key.ENTER) {
|
||||||
// TODO use getKeyBindingsManager().getMessageComposerAction(event) like in useInputEventProcessor
|
// TODO use getKeyBindingsManager().getMessageComposerAction(event) like in useInputEventProcessor
|
||||||
const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -14,10 +14,13 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MutableRefObject } from "react";
|
import { MutableRefObject, RefObject } from "react";
|
||||||
|
|
||||||
import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
|
import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
|
||||||
import { IRoomState } from "../../../../structures/RoomView";
|
import { IRoomState } from "../../../../structures/RoomView";
|
||||||
|
import Autocomplete from "../../Autocomplete";
|
||||||
|
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
|
||||||
|
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
|
||||||
|
|
||||||
export function focusComposer(
|
export function focusComposer(
|
||||||
composerElement: MutableRefObject<HTMLElement | null>,
|
composerElement: MutableRefObject<HTMLElement | null>,
|
||||||
|
@ -51,3 +54,59 @@ export function setCursorPositionAtTheEnd(element: HTMLElement): void {
|
||||||
|
|
||||||
element.focus();
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -21,6 +21,8 @@ import userEvent from "@testing-library/user-event";
|
||||||
import { PlainTextComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer";
|
import { PlainTextComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer";
|
||||||
import * as mockUseSettingsHook from "../../../../../../src/hooks/useSettings";
|
import * as mockUseSettingsHook from "../../../../../../src/hooks/useSettings";
|
||||||
import * as mockKeyboard from "../../../../../../src/Keyboard";
|
import * as mockKeyboard from "../../../../../../src/Keyboard";
|
||||||
|
import { createMocks } from "../utils";
|
||||||
|
import RoomContext from "../../../../../../src/contexts/RoomContext";
|
||||||
|
|
||||||
describe("PlainTextComposer", () => {
|
describe("PlainTextComposer", () => {
|
||||||
const customRender = (
|
const customRender = (
|
||||||
|
@ -271,4 +273,21 @@ describe("PlainTextComposer", () => {
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
(global.ResizeObserver as jest.Mock).mockRestore();
|
(global.ResizeObserver as jest.Mock).mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Should not render <Autocomplete /> if not wrapped in room context", () => {
|
||||||
|
customRender();
|
||||||
|
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should render <Autocomplete /> if wrapped in room context", () => {
|
||||||
|
const { defaultRoomContext } = createMocks();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoomContext.Provider value={defaultRoomContext}>
|
||||||
|
<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()} disabled={false} initialContent="" />
|
||||||
|
</RoomContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("autocomplete-wrapper")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -298,6 +298,28 @@ describe("WysiwygComposer", () => {
|
||||||
expect(screen.getByRole("link", { name: mockCompletions[0].completion })).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: mockCompletions[0].completion })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pressing escape closes the autocomplete", async () => {
|
||||||
|
await insertMentionInput();
|
||||||
|
|
||||||
|
// press escape
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
|
||||||
|
// check that it closes the autocomplete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("typing with the autocomplete open still works as expected", async () => {
|
||||||
|
await insertMentionInput();
|
||||||
|
|
||||||
|
// add some more text, then check the autocomplete is open AND the text is in the composer
|
||||||
|
await userEvent.keyboard("extra");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("presentation")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("textbox")).toHaveTextContent("@abcextra");
|
||||||
|
});
|
||||||
|
|
||||||
it("clicking on a mention in the composer dispatches the correct action", async () => {
|
it("clicking on a mention in the composer dispatches the correct action", async () => {
|
||||||
await insertMentionInput();
|
await insertMentionInput();
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,179 @@
|
||||||
|
/*
|
||||||
|
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 React from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Suggestion,
|
||||||
|
mapSuggestion,
|
||||||
|
processCommand,
|
||||||
|
processSelectionChange,
|
||||||
|
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
|
||||||
|
|
||||||
|
function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
|
||||||
|
return {
|
||||||
|
keyChar: "/",
|
||||||
|
type: "command",
|
||||||
|
text: "some text",
|
||||||
|
node: document.createTextNode(""),
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: 0,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mapSuggestion", () => {
|
||||||
|
it("returns null if called with a null argument", () => {
|
||||||
|
expect(mapSuggestion(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a mapped suggestion when passed a suggestion", () => {
|
||||||
|
const inputFields = {
|
||||||
|
keyChar: "/" as const,
|
||||||
|
type: "command" as const,
|
||||||
|
text: "some text",
|
||||||
|
};
|
||||||
|
const input = createMockPlainTextSuggestionPattern(inputFields);
|
||||||
|
const output = mapSuggestion(input);
|
||||||
|
|
||||||
|
expect(output).toEqual(inputFields);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processCommand", () => {
|
||||||
|
it("does not change parent hook state if suggestion is null", () => {
|
||||||
|
// create a mockSuggestion using the text node above
|
||||||
|
const mockSetSuggestion = jest.fn();
|
||||||
|
const mockSetText = jest.fn();
|
||||||
|
|
||||||
|
// call the function with a null suggestion
|
||||||
|
processCommand("should not be seen", null, mockSetSuggestion, mockSetText);
|
||||||
|
|
||||||
|
// check that the parent state setter has not been called
|
||||||
|
expect(mockSetText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can change the parent hook state when required", () => {
|
||||||
|
// create a div and append a text node to it with some initial text
|
||||||
|
const editorDiv = document.createElement("div");
|
||||||
|
const initialText = "text";
|
||||||
|
const textNode = document.createTextNode(initialText);
|
||||||
|
editorDiv.appendChild(textNode);
|
||||||
|
|
||||||
|
// create a mockSuggestion using the text node above
|
||||||
|
const mockSuggestion = createMockPlainTextSuggestionPattern({ node: textNode });
|
||||||
|
const mockSetSuggestion = jest.fn();
|
||||||
|
const mockSetText = jest.fn();
|
||||||
|
const replacementText = "/replacement text";
|
||||||
|
|
||||||
|
processCommand(replacementText, mockSuggestion, mockSetSuggestion, mockSetText);
|
||||||
|
|
||||||
|
// check that the text has changed and includes a trailing space
|
||||||
|
expect(mockSetText).toHaveBeenCalledWith(`${replacementText} `);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processSelectionChange", () => {
|
||||||
|
function createMockEditorRef(element: HTMLDivElement | null = null): React.RefObject<HTMLDivElement> {
|
||||||
|
return { current: element } as React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendEditorWithTextNodeContaining(initialText = ""): [HTMLDivElement, Node] {
|
||||||
|
// create the elements/nodes
|
||||||
|
const mockEditor = document.createElement("div");
|
||||||
|
const textNode = document.createTextNode(initialText);
|
||||||
|
|
||||||
|
// append text node to the editor, editor to the document body
|
||||||
|
mockEditor.appendChild(textNode);
|
||||||
|
document.body.appendChild(mockEditor);
|
||||||
|
|
||||||
|
return [mockEditor, textNode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockSetSuggestion = jest.fn();
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSetSuggestion.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early if current editorRef is null", () => {
|
||||||
|
const mockEditorRef = createMockEditorRef(null);
|
||||||
|
// we monitor for the call to document.createNodeIterator to indicate an early return
|
||||||
|
const nodeIteratorSpy = jest.spyOn(document, "createNodeIterator");
|
||||||
|
|
||||||
|
processSelectionChange(mockEditorRef, null, jest.fn());
|
||||||
|
expect(nodeIteratorSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// tidy up to avoid potential impacts on other tests
|
||||||
|
nodeIteratorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call setSuggestion if selection is not a cursor", () => {
|
||||||
|
const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
|
||||||
|
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||||
|
|
||||||
|
// create a selection in the text node that has different start and end locations ie it
|
||||||
|
// is not a cursor
|
||||||
|
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 4);
|
||||||
|
|
||||||
|
// process the selection and check that we do not attempt to set the suggestion
|
||||||
|
processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
|
||||||
|
expect(mockSetSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call setSuggestion if selection cursor is not inside a text node", () => {
|
||||||
|
const [mockEditor] = appendEditorWithTextNodeContaining("content");
|
||||||
|
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||||
|
|
||||||
|
// create a selection that points at the editor element, not the text node it contains
|
||||||
|
document.getSelection()?.setBaseAndExtent(mockEditor, 0, mockEditor, 0);
|
||||||
|
|
||||||
|
// process the selection and check that we do not attempt to set the suggestion
|
||||||
|
processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
|
||||||
|
expect(mockSetSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls setSuggestion with null if we have an existing suggestion but no command match", () => {
|
||||||
|
const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
|
||||||
|
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||||
|
|
||||||
|
// create a selection in the text node that has identical start and end locations, ie it is a cursor
|
||||||
|
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 0);
|
||||||
|
|
||||||
|
// the call to process the selection will have an existing suggestion in state due to the second
|
||||||
|
// argument being non-null, expect that we clear this suggestion now that the text is not a command
|
||||||
|
processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
|
||||||
|
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls setSuggestion with the expected arguments when text node is valid command", () => {
|
||||||
|
const commandText = "/potentialCommand";
|
||||||
|
const [mockEditor, textNode] = appendEditorWithTextNodeContaining(commandText);
|
||||||
|
const mockEditorRef = createMockEditorRef(mockEditor);
|
||||||
|
|
||||||
|
// create a selection in the text node that has identical start and end locations, ie it is a cursor
|
||||||
|
document.getSelection()?.setBaseAndExtent(textNode, 3, textNode, 3);
|
||||||
|
|
||||||
|
// process the change and check the suggestion that is set looks as we expect it to
|
||||||
|
processSelectionChange(mockEditorRef, null, mockSetSuggestion);
|
||||||
|
expect(mockSetSuggestion).toHaveBeenCalledWith({
|
||||||
|
keyChar: "/",
|
||||||
|
type: "command",
|
||||||
|
text: "potentialCommand",
|
||||||
|
node: textNode,
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: commandText.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue