mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-15 20:54:59 +08:00
Add RTE keyboard navigation in editing (#9980)
Add Keyboard navigation in editing
This commit is contained in:
parent
8161da1054
commit
afda774471
@ -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<ComposerContextState>): { 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<ComposerContextState>(getDefaultContextValue());
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 });
|
||||
|
||||
|
@ -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<boolean>("MessageComposerInput.ctrlEnterToSend");
|
||||
|
||||
export function useInputEventProcessor(onSend: () => void): (event: WysiwygEvent) => WysiwygEvent | null {
|
||||
const isCtrlEnter = useSettingValue<boolean>("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<WysiwygEvent, KeyboardEvent | ClipboardEvent>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
@ -77,6 +77,7 @@ export function usePlainTextListeners(
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
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
|
||||
|
46
src/components/views/rooms/wysiwyg_composer/utils/event.ts
Normal file
46
src/components/views/rooms/wysiwyg_composer/utils/event.ts
Normal file
@ -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);
|
||||
}
|
@ -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 <br> 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;
|
||||
}
|
||||
|
@ -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 <b>to</b> 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 <strong>to</strong> 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(
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<RoomContext.Provider value={defaultRoomContext}>
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<RoomContext.Provider value={roomContext}>
|
||||
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
|
||||
</RoomContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
@ -176,12 +200,13 @@ describe("EditWysiwygComposer", () => {
|
||||
});
|
||||
|
||||
describe("Edit and save actions", () => {
|
||||
let spyDispatcher: jest.SpyInstance<void, [payload: ActionPayload, sync?: boolean]>;
|
||||
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(
|
||||
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
|
||||
);
|
||||
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(
|
||||
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
|
||||
);
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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"));
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user