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:
parent
0a22ed90ef
commit
ca25c8f430
9 changed files with 626 additions and 48 deletions
|
@ -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…
Add table
Add a link
Reference in a new issue