diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts index 0ed6733416..85d1477116 100644 --- a/cypress/e2e/composer/composer.spec.ts +++ b/cypress/e2e/composer/composer.spec.ts @@ -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"); diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index d68c526064..c6abc1230b 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -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} > + - {children?.(ref, composerFunctions)} + {children?.(editorRef, composerFunctions)} ); } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index cf3e7d6be1..9fcad115f2 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -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, ): 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) { diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index f918ceb50d..8369d2684f 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -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; + autocompleteRef: React.RefObject; content?: string; onInput(event: SyntheticEvent): void; onPaste(event: SyntheticEvent): void; onKeyDown(event: KeyboardEvent): void; setContent(text: string): void; + handleMention: (link: string, text: string, attributes: Attributes) => void; + handleCommand: (text: string) => void; + onSelect: (event: SyntheticEvent) => void; + suggestion: MappedSuggestion | null; } { const ref = useRef(null); + const autocompleteRef = useRef(null); const [content, setContent] = useState(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("MessageComposerInput.ctrlEnterToSend"); const onInput = useCallback( (event: SyntheticEvent) => { @@ -76,6 +109,13 @@ export function usePlainTextListeners( const onKeyDown = useCallback( (event: KeyboardEvent) => { + // 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, + }; } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts new file mode 100644 index 0000000000..e1db110847 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -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, + setText: (text: string) => void, +): { + handleMention: (link: string, text: string, attributes: Attributes) => void; + handleCommand: (text: string) => void; + onSelect: (event: SyntheticEvent) => void; + suggestion: MappedSuggestion | null; +} { + const [suggestion, setSuggestion] = useState(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>, + 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, + suggestion: SuggestionState, + setSuggestion: React.Dispatch>, +): 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

tags inside the editor

+ 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, + }); + } +}; diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 83a18fb55b..636b5d2bf2 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -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, @@ -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, + // we get a React Keyboard event from plain text composer, a Keyboard Event from the rich text composer + event: KeyboardEvent | React.KeyboardEvent, +): 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; +} diff --git a/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx index e3b1b2fce1..9277ecb16c 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx @@ -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 if not wrapped in room context", () => { + customRender(); + expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument(); + }); + + it("Should render if wrapped in room context", () => { + const { defaultRoomContext } = createMocks(); + + render( + + + , + ); + + expect(screen.getByTestId("autocomplete-wrapper")).toBeInTheDocument(); + }); }); diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index 25f3c8815e..b9720652bc 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -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(); diff --git a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx new file mode 100644 index 0000000000..c25a869a09 --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -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 { + 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 { + return { current: element } as React.RefObject; + } + + 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, + }); + }); +});