Improve switching between rich and plain editing modes (#9776)
* allows switching between modes that retains formatting * updates rich text composer dependency to 0.13.0 (@matrix-org/matrix-wysiwyg) * improves handling of enter keypresses when ctrlEnterTosend setting is true in plain text editor * changes the message event content when using the new editor * adds tests for the changes to the plain text editor
This commit is contained in:
parent
3bcea5fb0b
commit
432ce3ca31
13 changed files with 336 additions and 94 deletions
|
@ -54,9 +54,8 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
|||
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
|
||||
import { Features } from "../../../settings/Settings";
|
||||
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
|
||||
import { SendWysiwygComposer, sendMessage } from "./wysiwyg_composer/";
|
||||
import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysiwyg_composer/";
|
||||
import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext";
|
||||
import { htmlToPlainText } from "../../../utils/room/htmlToPlaintext";
|
||||
import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording";
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
|
||||
|
@ -333,7 +332,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
|||
|
||||
if (this.state.isWysiwygLabEnabled) {
|
||||
const { permalinkCreator, relation, replyToEvent } = this.props;
|
||||
sendMessage(this.state.composerContent, this.state.isRichTextEnabled, {
|
||||
await sendMessage(this.state.composerContent, this.state.isRichTextEnabled, {
|
||||
mxClient: this.props.mxClient,
|
||||
roomContext: this.context,
|
||||
permalinkCreator,
|
||||
|
@ -358,14 +357,19 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onRichTextToggle = () => {
|
||||
this.setState((state) => ({
|
||||
isRichTextEnabled: !state.isRichTextEnabled,
|
||||
initialComposerContent: !state.isRichTextEnabled
|
||||
? state.composerContent
|
||||
: // TODO when available use rust model plain text
|
||||
htmlToPlainText(state.composerContent),
|
||||
}));
|
||||
private onRichTextToggle = async () => {
|
||||
const { richToPlain, plainToRich } = await getConversionFunctions();
|
||||
|
||||
const { isRichTextEnabled, composerContent } = this.state;
|
||||
const convertedContent = isRichTextEnabled
|
||||
? await richToPlain(composerContent)
|
||||
: await plainToRich(composerContent);
|
||||
|
||||
this.setState({
|
||||
isRichTextEnabled: !isRichTextEnabled,
|
||||
composerContent: convertedContent,
|
||||
initialComposerContent: convertedContent,
|
||||
});
|
||||
};
|
||||
|
||||
private onVoiceStoreUpdate = () => {
|
||||
|
|
|
@ -16,9 +16,25 @@ limitations under the License.
|
|||
|
||||
import React, { ComponentProps, lazy, Suspense } from "react";
|
||||
|
||||
// we need to import the types for TS, but do not import the sendMessage
|
||||
// function to avoid importing from "@matrix-org/matrix-wysiwyg"
|
||||
import { SendMessageParams } from "./utils/message";
|
||||
|
||||
const SendComposer = lazy(() => import("./SendWysiwygComposer"));
|
||||
const EditComposer = lazy(() => import("./EditWysiwygComposer"));
|
||||
|
||||
export const dynamicImportSendMessage = async (message: string, isHTML: boolean, params: SendMessageParams) => {
|
||||
const { sendMessage } = await import("./utils/message");
|
||||
|
||||
return sendMessage(message, isHTML, params);
|
||||
};
|
||||
|
||||
export const dynamicImportConversionFunctions = async () => {
|
||||
const { richToPlain, plainToRich } = await import("@matrix-org/matrix-wysiwyg");
|
||||
|
||||
return { richToPlain, plainToRich };
|
||||
};
|
||||
|
||||
export function DynamicImportSendWysiwygComposer(props: ComponentProps<typeof SendComposer>) {
|
||||
return (
|
||||
<Suspense fallback={<div />}>
|
||||
|
|
|
@ -17,11 +17,22 @@ limitations under the License.
|
|||
import { KeyboardEvent, SyntheticEvent, useCallback, useRef, useState } from "react";
|
||||
|
||||
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||
import { IS_MAC, Key } from "../../../../../Keyboard";
|
||||
|
||||
function isDivElement(target: EventTarget): target is HTMLDivElement {
|
||||
return target instanceof HTMLDivElement;
|
||||
}
|
||||
|
||||
// Hitting enter inside the editor inserts an editable div, initially containing a <br />
|
||||
// For correct display, first replace this pattern with a newline character and then remove divs
|
||||
// noting that they are used to delimit paragraphs
|
||||
function amendInnerHtml(text: string) {
|
||||
return text
|
||||
.replace(/<div><br><\/div>/g, "\n") // this is pressing enter then not typing
|
||||
.replace(/<div>/g, "\n") // this is from pressing enter, then typing inside the div
|
||||
.replace(/<\/div>/g, "");
|
||||
}
|
||||
|
||||
export function usePlainTextListeners(
|
||||
initialContent?: string,
|
||||
onChange?: (content: string) => void,
|
||||
|
@ -44,25 +55,39 @@ export function usePlainTextListeners(
|
|||
[onChange],
|
||||
);
|
||||
|
||||
const enterShouldSend = !useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
|
||||
const onInput = useCallback(
|
||||
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
|
||||
if (isDivElement(event.target)) {
|
||||
setText(event.target.innerHTML);
|
||||
// if enterShouldSend, we do not need to amend the html before setting text
|
||||
const newInnerHTML = enterShouldSend ? event.target.innerHTML : amendInnerHtml(event.target.innerHTML);
|
||||
setText(newInnerHTML);
|
||||
}
|
||||
},
|
||||
[setText],
|
||||
[setText, enterShouldSend],
|
||||
);
|
||||
|
||||
const isCtrlEnter = useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && (!isCtrlEnter || (isCtrlEnter && event.ctrlKey))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
send();
|
||||
if (event.key === Key.ENTER) {
|
||||
const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;
|
||||
|
||||
// if enter should send, send if the user is not pushing shift
|
||||
if (enterShouldSend && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
send();
|
||||
}
|
||||
|
||||
// if enter should not send, send only if the user is pushing ctrl/cmd
|
||||
if (!enterShouldSend && sendModifierIsPressed) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
send();
|
||||
}
|
||||
}
|
||||
},
|
||||
[isCtrlEnter, send],
|
||||
[enterShouldSend, send],
|
||||
);
|
||||
|
||||
return { ref, onInput, onPaste: onInput, onKeyDown, content, setContent: setText };
|
||||
|
|
|
@ -17,5 +17,6 @@ limitations under the License.
|
|||
export {
|
||||
DynamicImportSendWysiwygComposer as SendWysiwygComposer,
|
||||
DynamicImportEditWysiwygComposer as EditWysiwygComposer,
|
||||
dynamicImportSendMessage as sendMessage,
|
||||
dynamicImportConversionFunctions as getConversionFunctions,
|
||||
} from "./DynamicImportWysiwygComposer";
|
||||
export { sendMessage } from "./utils/message";
|
||||
|
|
|
@ -14,13 +14,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { richToPlain, plainToRich } from "@matrix-org/matrix-wysiwyg";
|
||||
import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { htmlSerializeFromMdIfNeeded } from "../../../../../editor/serialize";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
|
||||
import { addReplyToMessageContent } from "../../../../../utils/Reply";
|
||||
import { htmlToPlainText } from "../../../../../utils/room/htmlToPlaintext";
|
||||
|
||||
// Merges favouring the given relation
|
||||
function attachRelation(content: IContent, relation?: IEventRelation): void {
|
||||
|
@ -62,7 +61,7 @@ interface CreateMessageContentParams {
|
|||
editedEvent?: MatrixEvent;
|
||||
}
|
||||
|
||||
export function createMessageContent(
|
||||
export async function createMessageContent(
|
||||
message: string,
|
||||
isHTML: boolean,
|
||||
{
|
||||
|
@ -72,7 +71,7 @@ export function createMessageContent(
|
|||
includeReplyLegacyFallback = true,
|
||||
editedEvent,
|
||||
}: CreateMessageContentParams,
|
||||
): IContent {
|
||||
): Promise<IContent> {
|
||||
// TODO emote ?
|
||||
|
||||
const isEditing = Boolean(editedEvent);
|
||||
|
@ -90,26 +89,22 @@ export function createMessageContent(
|
|||
|
||||
// const body = textSerialize(model);
|
||||
|
||||
// TODO remove this ugly hack for replace br tag
|
||||
const body = (isHTML && htmlToPlainText(message)) || message.replace(/<br>/g, "\n");
|
||||
// if we're editing rich text, the message content is pure html
|
||||
// BUT if we're not, the message content will be plain text
|
||||
const body = isHTML ? await richToPlain(message) : message;
|
||||
const bodyPrefix = (isReplyAndEditing && getTextReplyFallback(editedEvent)) || "";
|
||||
const formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || "";
|
||||
|
||||
const content: IContent = {
|
||||
// TODO emote
|
||||
msgtype: MsgType.Text,
|
||||
// TODO when available, use HTML --> Plain text conversion from wysiwyg rust model
|
||||
body: isEditing ? `${bodyPrefix} * ${body}` : body,
|
||||
};
|
||||
|
||||
// TODO markdown support
|
||||
|
||||
const isMarkdownEnabled = SettingsStore.getValue<boolean>("MessageComposerInput.useMarkdown");
|
||||
const formattedBody = isHTML
|
||||
? message
|
||||
: isMarkdownEnabled
|
||||
? htmlSerializeFromMdIfNeeded(message, { forceHTML: isReply })
|
||||
: null;
|
||||
const formattedBody = isHTML ? message : isMarkdownEnabled ? await plainToRich(message) : null;
|
||||
|
||||
if (formattedBody) {
|
||||
content.format = "org.matrix.custom.html";
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
|
||||
import { IContent, IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
||||
|
||||
|
@ -34,7 +34,7 @@ import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
|
|||
import { createMessageContent } from "./createMessageContent";
|
||||
import { isContentModified } from "./isContentModified";
|
||||
|
||||
interface SendMessageParams {
|
||||
export interface SendMessageParams {
|
||||
mxClient: MatrixClient;
|
||||
relation?: IEventRelation;
|
||||
replyToEvent?: MatrixEvent;
|
||||
|
@ -43,10 +43,18 @@ interface SendMessageParams {
|
|||
includeReplyLegacyFallback?: boolean;
|
||||
}
|
||||
|
||||
export function sendMessage(message: string, isHTML: boolean, { roomContext, mxClient, ...params }: SendMessageParams) {
|
||||
export async function sendMessage(
|
||||
message: string,
|
||||
isHTML: boolean,
|
||||
{ roomContext, mxClient, ...params }: SendMessageParams,
|
||||
) {
|
||||
const { relation, replyToEvent } = params;
|
||||
const { room } = roomContext;
|
||||
const { roomId } = room;
|
||||
const roomId = room?.roomId;
|
||||
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const posthogEvent: ComposerEvent = {
|
||||
eventName: "Composer",
|
||||
|
@ -63,7 +71,7 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
|
|||
}*/
|
||||
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);
|
||||
|
||||
let content: IContent;
|
||||
const content = await createMessageContent(message, isHTML, params);
|
||||
|
||||
// TODO slash comment
|
||||
|
||||
|
@ -71,10 +79,6 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
|
|||
|
||||
// TODO quick reaction
|
||||
|
||||
if (!content) {
|
||||
content = createMessageContent(message, isHTML, params);
|
||||
}
|
||||
|
||||
// don't bother sending an empty message
|
||||
if (!content.body.trim()) {
|
||||
return;
|
||||
|
@ -84,7 +88,7 @@ export function sendMessage(message: string, isHTML: boolean, { roomContext, mxC
|
|||
decorateStartSendingTime(content);
|
||||
}
|
||||
|
||||
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
|
||||
const threadId = relation?.event_id && relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
|
||||
|
||||
const prom = doMaybeLocalRoomAction(
|
||||
roomId,
|
||||
|
@ -139,7 +143,7 @@ interface EditMessageParams {
|
|||
editorStateTransfer: EditorStateTransfer;
|
||||
}
|
||||
|
||||
export function editMessage(html: string, { roomContext, mxClient, editorStateTransfer }: EditMessageParams) {
|
||||
export async function editMessage(html: string, { roomContext, mxClient, editorStateTransfer }: EditMessageParams) {
|
||||
const editedEvent = editorStateTransfer.getEvent();
|
||||
|
||||
PosthogAnalytics.instance.trackEvent<ComposerEvent>({
|
||||
|
@ -156,7 +160,7 @@ export function editMessage(html: string, { roomContext, mxClient, editorStateTr
|
|||
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
|
||||
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
|
||||
}*/
|
||||
const editContent = createMessageContent(html, true, { editedEvent });
|
||||
const editContent = await createMessageContent(html, true, { editedEvent });
|
||||
const newContent = editContent["m.new_content"];
|
||||
|
||||
const shouldSend = true;
|
||||
|
@ -174,10 +178,10 @@ export function editMessage(html: string, { roomContext, mxClient, editorStateTr
|
|||
|
||||
let response: Promise<ISendEventResponse> | undefined;
|
||||
|
||||
// If content is modified then send an updated event into the room
|
||||
if (isContentModified(newContent, editorStateTransfer)) {
|
||||
const roomId = editedEvent.getRoomId();
|
||||
const roomId = editedEvent.getRoomId();
|
||||
|
||||
// If content is modified then send an updated event into the room
|
||||
if (isContentModified(newContent, editorStateTransfer) && roomId) {
|
||||
// TODO Slash Commands
|
||||
|
||||
if (shouldSend) {
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
/*
|
||||
Copyright 2022 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.
|
||||
*/
|
||||
|
||||
export function htmlToPlainText(html: string) {
|
||||
return new DOMParser().parseFromString(html, "text/html").documentElement.textContent;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue