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");
|
||||
});
|
||||
|
||||
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");
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -21,6 +21,8 @@ import userEvent from "@testing-library/user-event";
|
|||
import { PlainTextComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer";
|
||||
import * as mockUseSettingsHook from "../../../../../../src/hooks/useSettings";
|
||||
import * as mockKeyboard from "../../../../../../src/Keyboard";
|
||||
import { createMocks } from "../utils";
|
||||
import RoomContext from "../../../../../../src/contexts/RoomContext";
|
||||
|
||||
describe("PlainTextComposer", () => {
|
||||
const customRender = (
|
||||
|
@ -271,4 +273,21 @@ describe("PlainTextComposer", () => {
|
|||
jest.useRealTimers();
|
||||
(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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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