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:
alunturner 2023-01-04 12:57:09 +00:00 committed by GitHub
parent 3bcea5fb0b
commit 432ce3ca31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 336 additions and 94 deletions

View file

@ -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 = () => {

View file

@ -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 />}>

View file

@ -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 };

View file

@ -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";

View file

@ -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";

View file

@ -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) {