diff --git a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts index c8a369ead29b..207f432053f6 100644 --- a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts +++ b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts @@ -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): { selection: SubSelection } { return { selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0 }, + ...defaultValue, }; } export interface ComposerContextState { selection: SubSelection; + editorStateTransfer?: EditorStateTransfer; } export const ComposerContext = createContext(getDefaultContextValue()); diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 502afc962267..c0915469e2b0 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -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; diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index eb0e3c068fe3..42a375143b12 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -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 }); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index ce43f7a6f94d..96c62436cedf 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -14,22 +14,110 @@ 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 { 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 { MatrixClient, MatrixEvent } from "../../../../../../../matrix-js-sdk"; +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; +import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; +import { isCaretAtStart } from "../utils/selection"; + +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 isCtrlEnter = useSettingValue("MessageComposerInput.ctrlEnterToSend"); + + return useCallback( + (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => { + if (event instanceof ClipboardEvent) { + return event; + } + + 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, isCtrlEnter); + } + }, + [isCtrlEnter, onSend, initialContent, roomContext, composerContext, mxClient], + ); +} type Send = () => void; -function handleKeyboardEvent(event: KeyboardEvent, send: Send): KeyboardEvent | null { +function handleKeyboardEvent( + event: KeyboardEvent, + send: Send, + initialContent: string, + composer: Wysiwyg, + editor: HTMLElement, + roomContext: IRoomState, + composerContext: ComposerContextState, + mxClient: MatrixClient, +): KeyboardEvent | null { const action = getKeyBindingsManager().getMessageComposerAction(event); switch (action) { case KeyBindingAction.SendMessage: send(); return null; + case KeyBindingAction.EditPrevMessage: { + const { editorStateTransfer } = composerContext; + + const isEditorModified = initialContent !== composer.content(); + + // 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 previousEvent = findEditableEvent({ + events: getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient), + isForward: false, + fromEventId: editorStateTransfer.getEvent().getId(), + }); + if (previousEvent) { + dis.dispatch({ + action: Action.EditEvent, + event: previousEvent, + timelineRenderingType: roomContext.timelineRenderingType, + }); + event.stopPropagation(); + event.preventDefault(); + } + return null; + } } return event; @@ -54,27 +142,14 @@ function handleInputEvent(event: InputEvent, send: Send, isCtrlEnter: boolean): return event; } -export function useInputEventProcessor(onSend: () => void): (event: WysiwygEvent) => WysiwygEvent | null { - const isCtrlEnter = useSettingValue("MessageComposerInput.ctrlEnterToSend"); - return useCallback( - (event: WysiwygEvent) => { - if (event instanceof ClipboardEvent) { - return event; - } - - const send = (): void => { - event.stopPropagation?.(); - event.preventDefault?.(); - onSend(); - }; - - const isKeyboardEvent = event instanceof KeyboardEvent; - if (isKeyboardEvent) { - return handleKeyboardEvent(event, send); - } else { - return handleInputEvent(event, send, isCtrlEnter); - } - }, - [isCtrlEnter, onSend], - ); +// From EditMessageComposer private get events(): MatrixEvent[] +function getEventsFromEditorStateTransfer( + editorStateTransfer: EditorStateTransfer, + roomContext: IRoomState, + mxClient: MatrixClient, +): MatrixEvent[] { + const liveTimelineEvents = roomContext.liveTimeline.getEvents(); + const pendingEvents = mxClient.getRoom(editorStateTransfer.getEvent().getRoomId()).getPendingEvents(); + const isInThread = Boolean(editorStateTransfer.getEvent().getThread()); + return liveTimelineEvents.concat(isInThread ? [] : pendingEvents); } diff --git a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts index e6a594451bbf..838ff7783f3c 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts @@ -34,3 +34,21 @@ export function isSelectionEmpty(): boolean { const selection = document.getSelection(); return Boolean(selection?.isCollapsed); } + +export function isCaretAtStart(editor: HTMLElement): Boolean { + const selection = document.getSelection(); + + 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)); + + return false; +}