Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Add RTE keyboard navigation in editing #9980

Merged
merged 21 commits into from
Feb 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
156b45b
Rewrite event handling in useInputEventProcessor.ts
florianduros Jan 23, 2023
b350d3c
Add keyboard UP behaviour for RTE
florianduros Jan 24, 2023
55dcbec
Add Downward navigation in RTE
florianduros Jan 25, 2023
43d20cc
Update @matrix-org/matrix-wysiwyg to 0.21.0
florianduros Jan 26, 2023
2cf2b14
Merge branch 'develop' into florianduros/feat/keyboard-movement-rte
florianduros Jan 26, 2023
c79201d
Fix TS strict errors
florianduros Jan 26, 2023
d94f388
Move getEventsFromEditorStateTransfer to a separate file
florianduros Jan 26, 2023
156b787
Close editing when end of message is reached
florianduros Jan 26, 2023
d5a218b
Fix incorrect js-sdk import
florianduros Jan 26, 2023
bd516e7
Add Keyboard Up test
florianduros Jan 27, 2023
d1ab860
Add test for Keyboard Down
florianduros Jan 30, 2023
c2ecf35
Merge branch 'develop' into florianduros/feat/keyboard-movement-rte
florianduros Jan 30, 2023
f29e5da
Fix TS errors
florianduros Jan 30, 2023
fc0cb02
Add test for editing closing
florianduros Jan 30, 2023
e69a7ac
Merge branch 'develop' into florianduros/feat/keyboard-movement-rte
florianduros Jan 30, 2023
7ff47fc
Merge branch 'develop' into florianduros/feat/keyboard-movement-rte
florianduros Jan 30, 2023
c5bb0be
Add cypress tests for Keyboard Navigation
florianduros Jan 31, 2023
5af8ac5
Merge branch 'develop' into florianduros/feat/keyboard-movement-rte
florianduros Jan 31, 2023
9dde317
Removed cypress tests :(
florianduros Jan 31, 2023
0a697fe
Merge branch 'develop' into florianduros/feat/keyboard-movement-rte
florianduros Feb 1, 2023
1fa5800
PR comments
florianduros Feb 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
return null;
}
};

return event;
const isKeyboardEvent = event instanceof KeyboardEvent;
if (isKeyboardEvent) {
return handleKeyboardEvent(
event,
send,
initialContent,
composer,
editor,
roomContext,
composerContext,
mxClient,
);
} else {
return handleInputEvent(event, send, isCtrlEnterToSend);
}
},
[isCtrlEnter, onSend],
[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;
}

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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/components/views/rooms/wysiwyg_composer/utils/event.ts
Original file line number Diff line number Diff line change
@@ -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);
}
47 changes: 47 additions & 0 deletions src/components/views/rooms/wysiwyg_composer/utils/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading