Add RTE keyboard navigation in editing (#9980)

Add Keyboard navigation in editing
This commit is contained in:
Florian Duros 2023-02-01 13:12:12 +01:00 committed by GitHub
parent 8161da1054
commit afda774471
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 585 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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