From afda774471b490dac088dc24cf7a6675433b33d7 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 1 Feb 2023 13:12:12 +0100 Subject: [PATCH] Add RTE keyboard navigation in editing (#9980) Add Keyboard navigation in editing --- .../rooms/wysiwyg_composer/ComposerContext.ts | 5 +- .../wysiwyg_composer/EditWysiwygComposer.tsx | 2 +- .../components/WysiwygComposer.tsx | 2 +- .../hooks/useInputEventProcessor.ts | 168 +++++++- .../hooks/usePlainTextListeners.ts | 1 + .../rooms/wysiwyg_composer/utils/event.ts | 46 +++ .../rooms/wysiwyg_composer/utils/selection.ts | 47 +++ .../EditWysiwygComposer-test.tsx | 360 ++++++++++++++++-- .../components/WysiwygComposer-test.tsx | 2 + 9 files changed, 585 insertions(+), 48 deletions(-) create mode 100644 src/components/views/rooms/wysiwyg_composer/utils/event.ts diff --git a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts index 582c883dfe..1de070216c 100644 --- a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts +++ b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts @@ -17,15 +17,18 @@ limitations under the License. import { createContext, useContext } from "react"; import { SubSelection } from "./types"; +import EditorStateTransfer from "../../../../utils/EditorStateTransfer"; -export function getDefaultContextValue(): { selection: SubSelection } { +export function getDefaultContextValue(defaultValue?: Partial): { selection: SubSelection } { return { selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, isForward: true }, + ...defaultValue, }; } export interface ComposerContextState { selection: SubSelection; + editorStateTransfer?: EditorStateTransfer; } export const ComposerContext = createContext(getDefaultContextValue()); diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 502afc9622..c0915469e2 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -52,7 +52,7 @@ export default function EditWysiwygComposer({ className, ...props }: EditWysiwygComposerProps): JSX.Element { - const defaultContextValue = useRef(getDefaultContextValue()); + const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer })); const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || initialContent !== undefined; diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index eb0e3c068f..42a375143b 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -47,7 +47,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({ rightComponent, children, }: WysiwygComposerProps) { - const inputEventProcessor = useInputEventProcessor(onSend); + const inputEventProcessor = useInputEventProcessor(onSend, initialContent); const { ref, isWysiwygReady, content, actionStates, wysiwyg } = useWysiwyg({ initialContent, inputEventProcessor }); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index 3a223fb6b1..52d5f19e33 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -14,40 +14,168 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { WysiwygEvent } from "@matrix-org/matrix-wysiwyg"; +import { Wysiwyg, WysiwygEvent } from "@matrix-org/matrix-wysiwyg"; import { useCallback } from "react"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { useSettingValue } from "../../../../../hooks/useSettings"; +import { getKeyBindingsManager } from "../../../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts"; +import { findEditableEvent } from "../../../../../utils/EventUtils"; +import dis from "../../../../../dispatcher/dispatcher"; +import { Action } from "../../../../../dispatcher/actions"; +import { useRoomContext } from "../../../../../contexts/RoomContext"; +import { IRoomState } from "../../../../structures/RoomView"; +import { ComposerContextState, useComposerContext } from "../ComposerContext"; +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; +import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; +import { isCaretAtEnd, isCaretAtStart } from "../utils/selection"; +import { getEventsFromEditorStateTransfer } from "../utils/event"; +import { endEditing } from "../utils/editing"; -function isEnterPressed(event: KeyboardEvent): boolean { - // Ugly but here we need to send the message only if Enter is pressed - // And we need to stop the event propagation on enter to avoid the composer to grow - return event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey; -} +export function useInputEventProcessor( + onSend: () => void, + initialContent?: string, +): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null { + const roomContext = useRoomContext(); + const composerContext = useComposerContext(); + const mxClient = useMatrixClientContext(); + const isCtrlEnterToSend = useSettingValue("MessageComposerInput.ctrlEnterToSend"); -export function useInputEventProcessor(onSend: () => void): (event: WysiwygEvent) => WysiwygEvent | null { - const isCtrlEnter = useSettingValue("MessageComposerInput.ctrlEnterToSend"); return useCallback( - (event: WysiwygEvent) => { + (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => { if (event instanceof ClipboardEvent) { return event; } - const isKeyboardEvent = event instanceof KeyboardEvent; - const isEnterPress = !isCtrlEnter && isKeyboardEvent && isEnterPressed(event); - const isInsertParagraph = !isCtrlEnter && !isKeyboardEvent && event.inputType === "insertParagraph"; - // sendMessage is sent when cmd+enter is pressed - const isSendMessage = isCtrlEnter && !isKeyboardEvent && event.inputType === "sendMessage"; - - if (isEnterPress || isInsertParagraph || isSendMessage) { + const send = (): void => { event.stopPropagation?.(); event.preventDefault?.(); onSend(); + }; + + const isKeyboardEvent = event instanceof KeyboardEvent; + if (isKeyboardEvent) { + return handleKeyboardEvent( + event, + send, + initialContent, + composer, + editor, + roomContext, + composerContext, + mxClient, + ); + } else { + return handleInputEvent(event, send, isCtrlEnterToSend); + } + }, + [isCtrlEnterToSend, onSend, initialContent, roomContext, composerContext, mxClient], + ); +} + +type Send = () => void; + +function handleKeyboardEvent( + event: KeyboardEvent, + send: Send, + initialContent: string | undefined, + composer: Wysiwyg, + editor: HTMLElement, + roomContext: IRoomState, + composerContext: ComposerContextState, + mxClient: MatrixClient, +): KeyboardEvent | null { + const { editorStateTransfer } = composerContext; + const isEditorModified = initialContent !== composer.content(); + const action = getKeyBindingsManager().getMessageComposerAction(event); + + switch (action) { + case KeyBindingAction.SendMessage: + send(); + return null; + case KeyBindingAction.EditPrevMessage: { + // If not in edition + // Or if the caret is not at the beginning of the editor + // Or the editor is modified + if (!editorStateTransfer || !isCaretAtStart(editor) || isEditorModified) { + break; + } + + const isDispatched = dispatchEditEvent(event, false, editorStateTransfer, roomContext, mxClient); + if (isDispatched) { return null; } - return event; - }, - [isCtrlEnter, onSend], - ); + break; + } + case KeyBindingAction.EditNextMessage: { + // If not in edition + // Or if the caret is not at the end of the editor + // Or the editor is modified + if (!editorStateTransfer || !isCaretAtEnd(editor) || isEditorModified) { + break; + } + + const isDispatched = dispatchEditEvent(event, true, editorStateTransfer, roomContext, mxClient); + if (!isDispatched) { + endEditing(roomContext); + event.preventDefault(); + event.stopPropagation(); + } + + return null; + } + } + + return event; +} + +function dispatchEditEvent( + event: KeyboardEvent, + isForward: boolean, + editorStateTransfer: EditorStateTransfer, + roomContext: IRoomState, + mxClient: MatrixClient, +): boolean { + const foundEvents = getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient); + if (!foundEvents) { + return false; + } + + const newEvent = findEditableEvent({ + events: foundEvents, + isForward, + fromEventId: editorStateTransfer.getEvent().getId(), + }); + if (newEvent) { + dis.dispatch({ + action: Action.EditEvent, + event: newEvent, + timelineRenderingType: roomContext.timelineRenderingType, + }); + event.stopPropagation(); + event.preventDefault(); + return true; + } + return false; +} + +type InputEvent = Exclude; + +function handleInputEvent(event: InputEvent, send: Send, isCtrlEnterToSend: boolean): InputEvent | null { + switch (event.inputType) { + case "insertParagraph": + if (!isCtrlEnterToSend) { + send(); + } + return null; + case "sendMessage": + if (isCtrlEnterToSend) { + send(); + } + return null; + } + + return event; } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index a21e526c4a..f8b045ad65 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -77,6 +77,7 @@ export function usePlainTextListeners( const onKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === Key.ENTER) { + // TODO use getKeyBindingsManager().getMessageComposerAction(event) like in useInputEventProcessor const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey; // if enter should send, send if the user is not pushing shift diff --git a/src/components/views/rooms/wysiwyg_composer/utils/event.ts b/src/components/views/rooms/wysiwyg_composer/utils/event.ts new file mode 100644 index 0000000000..4d65497fac --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/utils/event.ts @@ -0,0 +1,46 @@ +/* +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 { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; +import { IRoomState } from "../../../../structures/RoomView"; + +// From EditMessageComposer private get events(): MatrixEvent[] +export function getEventsFromEditorStateTransfer( + editorStateTransfer: EditorStateTransfer, + roomContext: IRoomState, + mxClient: MatrixClient, +): MatrixEvent[] | undefined { + const liveTimelineEvents = roomContext.liveTimeline?.getEvents(); + if (!liveTimelineEvents) { + return; + } + + const roomId = editorStateTransfer.getEvent().getRoomId(); + if (!roomId) { + return; + } + + const room = mxClient.getRoom(roomId); + if (!room) { + return; + } + + const pendingEvents = room.getPendingEvents(); + const isInThread = Boolean(editorStateTransfer.getEvent().getThread()); + return liveTimelineEvents.concat(isInThread ? [] : pendingEvents); +} diff --git a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts index ca515d06ee..4ed64154e5 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts @@ -39,3 +39,50 @@ export function isSelectionEmpty(): boolean { const selection = document.getSelection(); return Boolean(selection?.isCollapsed); } + +export function isCaretAtStart(editor: HTMLElement): boolean { + const selection = document.getSelection(); + + // No selection or the caret is not at the beginning of the selected element + if (!selection || selection.anchorOffset !== 0) { + return false; + } + + // In case of nested html elements (list, code blocks), we are going through all the first child + let child = editor.firstChild; + do { + if (child === selection.anchorNode) { + return true; + } + } while ((child = child?.firstChild || null)); + + return false; +} + +export function isCaretAtEnd(editor: HTMLElement): boolean { + const selection = document.getSelection(); + + if (!selection) { + return false; + } + + // When we are cycling across all the timeline message with the keyboard + // The caret is on the last text element but focusNode and anchorNode refers to the editor div + // In this case, the focusOffset & anchorOffset match the index + 1 of the selected text + const isOnLastElement = selection.focusNode === editor && selection.focusOffset === editor.childNodes?.length; + if (isOnLastElement) { + return true; + } + + // In case of nested html elements (list, code blocks), we are going through all the last child + // The last child of the editor is always a
tag, we skip it + let child: ChildNode | null = editor.childNodes.item(editor.childNodes.length - 2); + do { + if (child === selection.focusNode) { + // Checking that the cursor is at end of the selected text + return selection.focusOffset === child.textContent?.length; + } + } while ((child = child.lastChild)); + + return false; +} diff --git a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index d9343208c4..c2c7052e05 100644 --- a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -17,13 +17,21 @@ limitations under the License. import "@testing-library/jest-dom"; import React from "react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { EventTimeline, MatrixEvent } from "matrix-js-sdk/src/matrix"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import RoomContext from "../../../../../src/contexts/RoomContext"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; +import { + createTestClient, + flushPromises, + getRoomContext, + mkEvent, + mkStubRoom, + mockPlatformPeg, +} from "../../../../test-utils"; import { EditWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer"; import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; import { Emoji } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/Emoji"; @@ -32,38 +40,54 @@ import dis from "../../../../../src/dispatcher/dispatcher"; import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload"; import { ActionPayload } from "../../../../../src/dispatcher/payloads"; import * as EmojiButton from "../../../../../src/components/views/rooms/EmojiButton"; +import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; +import * as EventUtils from "../../../../../src/utils/EventUtils"; +import { SubSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/types"; describe("EditWysiwygComposer", () => { afterEach(() => { jest.resetAllMocks(); }); - const mockClient = createTestClient(); - const mockEvent = mkEvent({ - type: "m.room.message", - room: "myfakeroom", - user: "myfakeuser", - content: { - msgtype: "m.text", - body: "Replying to this", - format: "org.matrix.custom.html", - formatted_body: "Replying to this new content", - }, - event: true, - }); - const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any; - mockRoom.findEventById = jest.fn((eventId) => { - return eventId === mockEvent.getId() ? mockEvent : null; - }); + function createMocks(eventContent = "Replying to this new content") { + const mockClient = createTestClient(); + const mockEvent = mkEvent({ + type: "m.room.message", + room: "myfakeroom", + user: "myfakeuser", + content: { + msgtype: "m.text", + body: "Replying to this", + format: "org.matrix.custom.html", + formatted_body: eventContent, + }, + event: true, + }); + const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any; + mockRoom.findEventById = jest.fn((eventId) => { + return eventId === mockEvent.getId() ? mockEvent : null; + }); - const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {}); + const defaultRoomContext: IRoomState = getRoomContext(mockRoom, { + liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline, + }); - const editorStateTransfer = new EditorStateTransfer(mockEvent); + const editorStateTransfer = new EditorStateTransfer(mockEvent); - const customRender = (disabled = false, _editorStateTransfer = editorStateTransfer) => { + return { defaultRoomContext, editorStateTransfer, mockClient, mockEvent }; + } + + const { editorStateTransfer, defaultRoomContext, mockClient, mockEvent } = createMocks(); + + const customRender = ( + disabled = false, + _editorStateTransfer = editorStateTransfer, + client = mockClient, + roomContext = defaultRoomContext, + ) => { return render( - - + + , @@ -176,12 +200,13 @@ describe("EditWysiwygComposer", () => { }); describe("Edit and save actions", () => { + let spyDispatcher: jest.SpyInstance; beforeEach(async () => { + spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); customRender(); await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); }); - const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); afterEach(() => { spyDispatcher.mockRestore(); }); @@ -204,7 +229,6 @@ describe("EditWysiwygComposer", () => { it("Should send message on save button click", async () => { // When - const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); fireEvent.input(screen.getByRole("textbox"), { data: "foo bar", inputType: "insertText", @@ -318,4 +342,290 @@ describe("EditWysiwygComposer", () => { await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/)); dis.unregister(dispatcherRef); }); + + describe("Keyboard navigation", () => { + const setup = async ( + editorState = editorStateTransfer, + client = createTestClient(), + roomContext = defaultRoomContext, + ) => { + const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); + customRender(false, editorState, client, roomContext); + await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); + return { textbox: screen.getByRole("textbox"), spyDispatcher }; + }; + + beforeEach(() => { + mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) }); + jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent); + }); + + function select(selection: SubSelection) { + return act(async () => { + await setSelection(selection); + // the event is not automatically fired by jest + document.dispatchEvent(new CustomEvent("selectionchange")); + }); + } + + describe("Moving up", () => { + it("Should not moving when caret is not at beginning of the text", async () => { + // When + const { textbox, spyDispatcher } = await setup(); + const textNode = textbox.firstChild; + await select({ + anchorNode: textNode, + anchorOffset: 1, + focusNode: textNode, + focusOffset: 2, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowUp", + }); + + // Then + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it("Should not moving when the content has changed", async () => { + // When + const { textbox, spyDispatcher } = await setup(); + fireEvent.input(textbox, { + data: "word", + inputType: "insertText", + }); + const textNode = textbox.firstChild; + await select({ + anchorNode: textNode, + anchorOffset: 0, + focusNode: textNode, + focusOffset: 0, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowUp", + }); + + // Then + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it("Should moving up", async () => { + // When + const { textbox, spyDispatcher } = await setup(); + const textNode = textbox.firstChild; + await select({ + anchorNode: textNode, + anchorOffset: 0, + focusNode: textNode, + focusOffset: 0, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowUp", + }); + + // Wait for event dispatch to happen + await act(async () => { + await flushPromises(); + }); + + // Then + await waitFor(() => + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: mockEvent, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }), + ); + }); + + it("Should moving up in list", async () => { + // When + const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks( + "
  • Content
  • Other Content
", + ); + jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent); + const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext); + + const textNode = textbox.firstChild; + await select({ + anchorNode: textNode, + anchorOffset: 0, + focusNode: textNode, + focusOffset: 0, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowUp", + }); + + // Wait for event dispatch to happen + await act(async () => { + await flushPromises(); + }); + + // Then + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: mockEvent, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }); + }); + }); + + describe("Moving down", () => { + it("Should not moving when caret is not at the end of the text", async () => { + // When + const { textbox, spyDispatcher } = await setup(); + const brNode = textbox.lastChild; + await select({ + anchorNode: brNode, + anchorOffset: 0, + focusNode: brNode, + focusOffset: 0, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowDown", + }); + + // Then + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it("Should not moving when the content has changed", async () => { + // When + const { textbox, spyDispatcher } = await setup(); + fireEvent.input(textbox, { + data: "word", + inputType: "insertText", + }); + const brNode = textbox.lastChild; + await select({ + anchorNode: brNode, + anchorOffset: 0, + focusNode: brNode, + focusOffset: 0, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowDown", + }); + + // Then + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it("Should moving down", async () => { + // When + const { textbox, spyDispatcher } = await setup(); + // Skipping the BR tag + const textNode = textbox.childNodes[textbox.childNodes.length - 2]; + const { length } = textNode.textContent || ""; + await select({ + anchorNode: textNode, + anchorOffset: length, + focusNode: textNode, + focusOffset: length, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowDown", + }); + + // Wait for event dispatch to happen + await act(async () => { + await flushPromises(); + }); + + // Then + await waitFor(() => + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: mockEvent, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }), + ); + }); + + it("Should moving down in list", async () => { + // When + const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks( + "
  • Content
  • Other Content
", + ); + jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent); + const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext); + + // Skipping the BR tag and get the text node inside the last LI tag + const textNode = textbox.childNodes[textbox.childNodes.length - 2].lastChild?.lastChild || textbox; + const { length } = textNode.textContent || ""; + await select({ + anchorNode: textNode, + anchorOffset: length, + focusNode: textNode, + focusOffset: length, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowDown", + }); + + // Wait for event dispatch to happen + await act(async () => { + await flushPromises(); + }); + + // Then + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: mockEvent, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }); + }); + + it("Should close editing", async () => { + // When + jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(undefined); + const { textbox, spyDispatcher } = await setup(); + // Skipping the BR tag + const textNode = textbox.childNodes[textbox.childNodes.length - 2]; + const { length } = textNode.textContent || ""; + await select({ + anchorNode: textNode, + anchorOffset: length, + focusNode: textNode, + focusOffset: length, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowDown", + }); + + // Wait for event dispatch to happen + await act(async () => { + await flushPromises(); + }); + + // Then + await waitFor(() => + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: null, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }), + ); + }); + }); + }); }); 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 7353acb6b2..44f2a5a996 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -21,6 +21,7 @@ import userEvent from "@testing-library/user-event"; import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; import SettingsStore from "../../../../../../src/settings/SettingsStore"; +import { mockPlatformPeg } from "../../../../../test-utils"; describe("WysiwygComposer", () => { const customRender = ( @@ -46,6 +47,7 @@ describe("WysiwygComposer", () => { const onChange = jest.fn(); const onSend = jest.fn(); beforeEach(async () => { + mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) }); customRender(onChange, onSend); await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); });