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

Commit afda774

Browse files
authored
Add RTE keyboard navigation in editing (#9980)
Add Keyboard navigation in editing
1 parent 8161da1 commit afda774

File tree

9 files changed

+585
-48
lines changed

9 files changed

+585
-48
lines changed

src/components/views/rooms/wysiwyg_composer/ComposerContext.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,18 @@ limitations under the License.
1717
import { createContext, useContext } from "react";
1818

1919
import { SubSelection } from "./types";
20+
import EditorStateTransfer from "../../../../utils/EditorStateTransfer";
2021

21-
export function getDefaultContextValue(): { selection: SubSelection } {
22+
export function getDefaultContextValue(defaultValue?: Partial<ComposerContextState>): { selection: SubSelection } {
2223
return {
2324
selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, isForward: true },
25+
...defaultValue,
2426
};
2527
}
2628

2729
export interface ComposerContextState {
2830
selection: SubSelection;
31+
editorStateTransfer?: EditorStateTransfer;
2932
}
3033

3134
export const ComposerContext = createContext<ComposerContextState>(getDefaultContextValue());

src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default function EditWysiwygComposer({
5252
className,
5353
...props
5454
}: EditWysiwygComposerProps): JSX.Element {
55-
const defaultContextValue = useRef(getDefaultContextValue());
55+
const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer }));
5656
const initialContent = useInitialContent(editorStateTransfer);
5757
const isReady = !editorStateTransfer || initialContent !== undefined;
5858

src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
4747
rightComponent,
4848
children,
4949
}: WysiwygComposerProps) {
50-
const inputEventProcessor = useInputEventProcessor(onSend);
50+
const inputEventProcessor = useInputEventProcessor(onSend, initialContent);
5151

5252
const { ref, isWysiwygReady, content, actionStates, wysiwyg } = useWysiwyg({ initialContent, inputEventProcessor });
5353

src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts

+148-20
Original file line numberDiff line numberDiff line change
@@ -14,40 +14,168 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { WysiwygEvent } from "@matrix-org/matrix-wysiwyg";
17+
import { Wysiwyg, WysiwygEvent } from "@matrix-org/matrix-wysiwyg";
1818
import { useCallback } from "react";
19+
import { MatrixClient } from "matrix-js-sdk/src/matrix";
1920

2021
import { useSettingValue } from "../../../../../hooks/useSettings";
22+
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
23+
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
24+
import { findEditableEvent } from "../../../../../utils/EventUtils";
25+
import dis from "../../../../../dispatcher/dispatcher";
26+
import { Action } from "../../../../../dispatcher/actions";
27+
import { useRoomContext } from "../../../../../contexts/RoomContext";
28+
import { IRoomState } from "../../../../structures/RoomView";
29+
import { ComposerContextState, useComposerContext } from "../ComposerContext";
30+
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
31+
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
32+
import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
33+
import { getEventsFromEditorStateTransfer } from "../utils/event";
34+
import { endEditing } from "../utils/editing";
2135

22-
function isEnterPressed(event: KeyboardEvent): boolean {
23-
// Ugly but here we need to send the message only if Enter is pressed
24-
// And we need to stop the event propagation on enter to avoid the composer to grow
25-
return event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey;
26-
}
36+
export function useInputEventProcessor(
37+
onSend: () => void,
38+
initialContent?: string,
39+
): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null {
40+
const roomContext = useRoomContext();
41+
const composerContext = useComposerContext();
42+
const mxClient = useMatrixClientContext();
43+
const isCtrlEnterToSend = useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
2744

28-
export function useInputEventProcessor(onSend: () => void): (event: WysiwygEvent) => WysiwygEvent | null {
29-
const isCtrlEnter = useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
3045
return useCallback(
31-
(event: WysiwygEvent) => {
46+
(event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => {
3247
if (event instanceof ClipboardEvent) {
3348
return event;
3449
}
3550

36-
const isKeyboardEvent = event instanceof KeyboardEvent;
37-
const isEnterPress = !isCtrlEnter && isKeyboardEvent && isEnterPressed(event);
38-
const isInsertParagraph = !isCtrlEnter && !isKeyboardEvent && event.inputType === "insertParagraph";
39-
// sendMessage is sent when cmd+enter is pressed
40-
const isSendMessage = isCtrlEnter && !isKeyboardEvent && event.inputType === "sendMessage";
41-
42-
if (isEnterPress || isInsertParagraph || isSendMessage) {
51+
const send = (): void => {
4352
event.stopPropagation?.();
4453
event.preventDefault?.();
4554
onSend();
46-
return null;
47-
}
55+
};
4856

49-
return event;
57+
const isKeyboardEvent = event instanceof KeyboardEvent;
58+
if (isKeyboardEvent) {
59+
return handleKeyboardEvent(
60+
event,
61+
send,
62+
initialContent,
63+
composer,
64+
editor,
65+
roomContext,
66+
composerContext,
67+
mxClient,
68+
);
69+
} else {
70+
return handleInputEvent(event, send, isCtrlEnterToSend);
71+
}
5072
},
51-
[isCtrlEnter, onSend],
73+
[isCtrlEnterToSend, onSend, initialContent, roomContext, composerContext, mxClient],
5274
);
5375
}
76+
77+
type Send = () => void;
78+
79+
function handleKeyboardEvent(
80+
event: KeyboardEvent,
81+
send: Send,
82+
initialContent: string | undefined,
83+
composer: Wysiwyg,
84+
editor: HTMLElement,
85+
roomContext: IRoomState,
86+
composerContext: ComposerContextState,
87+
mxClient: MatrixClient,
88+
): KeyboardEvent | null {
89+
const { editorStateTransfer } = composerContext;
90+
const isEditorModified = initialContent !== composer.content();
91+
const action = getKeyBindingsManager().getMessageComposerAction(event);
92+
93+
switch (action) {
94+
case KeyBindingAction.SendMessage:
95+
send();
96+
return null;
97+
case KeyBindingAction.EditPrevMessage: {
98+
// If not in edition
99+
// Or if the caret is not at the beginning of the editor
100+
// Or the editor is modified
101+
if (!editorStateTransfer || !isCaretAtStart(editor) || isEditorModified) {
102+
break;
103+
}
104+
105+
const isDispatched = dispatchEditEvent(event, false, editorStateTransfer, roomContext, mxClient);
106+
if (isDispatched) {
107+
return null;
108+
}
109+
110+
break;
111+
}
112+
case KeyBindingAction.EditNextMessage: {
113+
// If not in edition
114+
// Or if the caret is not at the end of the editor
115+
// Or the editor is modified
116+
if (!editorStateTransfer || !isCaretAtEnd(editor) || isEditorModified) {
117+
break;
118+
}
119+
120+
const isDispatched = dispatchEditEvent(event, true, editorStateTransfer, roomContext, mxClient);
121+
if (!isDispatched) {
122+
endEditing(roomContext);
123+
event.preventDefault();
124+
event.stopPropagation();
125+
}
126+
127+
return null;
128+
}
129+
}
130+
131+
return event;
132+
}
133+
134+
function dispatchEditEvent(
135+
event: KeyboardEvent,
136+
isForward: boolean,
137+
editorStateTransfer: EditorStateTransfer,
138+
roomContext: IRoomState,
139+
mxClient: MatrixClient,
140+
): boolean {
141+
const foundEvents = getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient);
142+
if (!foundEvents) {
143+
return false;
144+
}
145+
146+
const newEvent = findEditableEvent({
147+
events: foundEvents,
148+
isForward,
149+
fromEventId: editorStateTransfer.getEvent().getId(),
150+
});
151+
if (newEvent) {
152+
dis.dispatch({
153+
action: Action.EditEvent,
154+
event: newEvent,
155+
timelineRenderingType: roomContext.timelineRenderingType,
156+
});
157+
event.stopPropagation();
158+
event.preventDefault();
159+
return true;
160+
}
161+
return false;
162+
}
163+
164+
type InputEvent = Exclude<WysiwygEvent, KeyboardEvent | ClipboardEvent>;
165+
166+
function handleInputEvent(event: InputEvent, send: Send, isCtrlEnterToSend: boolean): InputEvent | null {
167+
switch (event.inputType) {
168+
case "insertParagraph":
169+
if (!isCtrlEnterToSend) {
170+
send();
171+
}
172+
return null;
173+
case "sendMessage":
174+
if (isCtrlEnterToSend) {
175+
send();
176+
}
177+
return null;
178+
}
179+
180+
return event;
181+
}

src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts

+1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export function usePlainTextListeners(
7777
const onKeyDown = useCallback(
7878
(event: KeyboardEvent<HTMLDivElement>) => {
7979
if (event.key === Key.ENTER) {
80+
// TODO use getKeyBindingsManager().getMessageComposerAction(event) like in useInputEventProcessor
8081
const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;
8182

8283
// if enter should send, send if the user is not pushing shift
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
18+
19+
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
20+
import { IRoomState } from "../../../../structures/RoomView";
21+
22+
// From EditMessageComposer private get events(): MatrixEvent[]
23+
export function getEventsFromEditorStateTransfer(
24+
editorStateTransfer: EditorStateTransfer,
25+
roomContext: IRoomState,
26+
mxClient: MatrixClient,
27+
): MatrixEvent[] | undefined {
28+
const liveTimelineEvents = roomContext.liveTimeline?.getEvents();
29+
if (!liveTimelineEvents) {
30+
return;
31+
}
32+
33+
const roomId = editorStateTransfer.getEvent().getRoomId();
34+
if (!roomId) {
35+
return;
36+
}
37+
38+
const room = mxClient.getRoom(roomId);
39+
if (!room) {
40+
return;
41+
}
42+
43+
const pendingEvents = room.getPendingEvents();
44+
const isInThread = Boolean(editorStateTransfer.getEvent().getThread());
45+
return liveTimelineEvents.concat(isInThread ? [] : pendingEvents);
46+
}

src/components/views/rooms/wysiwyg_composer/utils/selection.ts

+47
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,50 @@ export function isSelectionEmpty(): boolean {
3939
const selection = document.getSelection();
4040
return Boolean(selection?.isCollapsed);
4141
}
42+
43+
export function isCaretAtStart(editor: HTMLElement): boolean {
44+
const selection = document.getSelection();
45+
46+
// No selection or the caret is not at the beginning of the selected element
47+
if (!selection || selection.anchorOffset !== 0) {
48+
return false;
49+
}
50+
51+
// In case of nested html elements (list, code blocks), we are going through all the first child
52+
let child = editor.firstChild;
53+
do {
54+
if (child === selection.anchorNode) {
55+
return true;
56+
}
57+
} while ((child = child?.firstChild || null));
58+
59+
return false;
60+
}
61+
62+
export function isCaretAtEnd(editor: HTMLElement): boolean {
63+
const selection = document.getSelection();
64+
65+
if (!selection) {
66+
return false;
67+
}
68+
69+
// When we are cycling across all the timeline message with the keyboard
70+
// The caret is on the last text element but focusNode and anchorNode refers to the editor div
71+
// In this case, the focusOffset & anchorOffset match the index + 1 of the selected text
72+
const isOnLastElement = selection.focusNode === editor && selection.focusOffset === editor.childNodes?.length;
73+
if (isOnLastElement) {
74+
return true;
75+
}
76+
77+
// In case of nested html elements (list, code blocks), we are going through all the last child
78+
// The last child of the editor is always a <br> tag, we skip it
79+
let child: ChildNode | null = editor.childNodes.item(editor.childNodes.length - 2);
80+
do {
81+
if (child === selection.focusNode) {
82+
// Checking that the cursor is at end of the selected text
83+
return selection.focusOffset === child.textContent?.length;
84+
}
85+
} while ((child = child.lastChild));
86+
87+
return false;
88+
}

0 commit comments

Comments
 (0)