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>
This commit is contained in:
alunturner 2023-04-27 08:37:47 +01:00 committed by GitHub
parent 0a22ed90ef
commit ca25c8f430
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 626 additions and 48 deletions

View file

@ -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");

View file

@ -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>
);
}

View file

@ -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
}
}
if (handled) {
event.preventDefault();
event.stopPropagation();
const isHandledByAutocomplete = handleEventWithAutocomplete(autocompleteRef, event);
if (isHandledByAutocomplete) {
return event;
}
// taking the client from context gives us an client | undefined type, narrow it down
if (mxClient === undefined) {
return null;
}
switch (action) {

View file

@ -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,
};
}

View file

@ -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,
});
}
};

View file

@ -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;
}

View file

@ -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();
});
});

View file

@ -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();

View file

@ -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,
});
});
});