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

Commit

Permalink
Add RTE keyboard navigation in editing (#9980)
Browse files Browse the repository at this point in the history
Add Keyboard navigation in editing
  • Loading branch information
florianduros committed Feb 1, 2023
1 parent 8161da1 commit afda774
Show file tree
Hide file tree
Showing 9 changed files with 585 additions and 48 deletions.
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

0 comments on commit afda774

Please sign in to comment.