Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: rich text editor [WPB-12089] #18520

Merged
merged 12 commits into from
Dec 18, 2024
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
"@datadog/browser-logs": "5.33.0",
"@datadog/browser-rum": "5.33.0",
"@emotion/react": "11.11.4",
"@lexical/code": "0.20.2",
"@lexical/history": "0.20.2",
"@lexical/list": "0.20.2",
"@lexical/markdown": "0.20.2",
"@lexical/react": "0.20.2",
"@lexical/rich-text": "0.20.2",
"@lexical/code": "0.21.0",
"@lexical/history": "0.21.0",
"@lexical/list": "0.21.0",
"@lexical/markdown": "0.21.0",
"@lexical/react": "0.21.0",
"@lexical/rich-text": "0.21.0",
"@mediapipe/tasks-vision": "0.10.20",
"@wireapp/avs": "10.0.4",
"@wireapp/avs-debugger": "0.0.7",
Expand All @@ -35,7 +35,7 @@
"kalium-backup": "./TEMP-crossplatform-backup",
"keyboardjs": "2.7.0",
"knockout": "3.5.1",
"lexical": "0.20.2",
"lexical": "0.21.0",
"libsodium-wrappers": "0.7.15",
"linkify-it": "5.0.0",
"long": "5.2.3",
Expand Down
24 changes: 15 additions & 9 deletions src/script/components/InputBar/InputBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ export const InputBar = ({

const enableSending = textValue.length > 0;

const showAvatar = messageFormatButtonsEnabled || !!textValue.length;
const showAvatar = !!textValue.length;

return (
<div ref={wrapperRef}>
Expand Down Expand Up @@ -627,14 +627,20 @@ export const InputBar = ({
onSend={handleSendMessage}
onBlur={() => isTypingRef.current && conversationRepository.sendTypingStop(conversation)}
>
<ul
className={cx('controls-right buttons-group input-bar-buttons', {
'controls-right-shrinked': textValue.length !== 0,
})}
>
<ControlButtons {...controlButtonsProps} showGiphyButton={showGiphyButton} />
<SendMessageButton disabled={!enableSending} onSend={handleSendMessage} />
</ul>
<div className="input-bar-buttons">
<ul
className={cx('controls-right buttons-group input-bar-buttons__list', {
'controls-right-shrinked': textValue.length !== 0,
})}
>
<ControlButtons {...controlButtonsProps} showGiphyButton={showGiphyButton} />
</ul>
<SendMessageButton
disabled={!enableSending}
onSend={handleSendMessage}
className="input-bar-buttons__send"
/>
</div>
</RichTextEditor>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ interface EmojiPickerParams {
onEmojiPicked: (emoji: string) => void;
}

const TRIGGER_WIDTH = 40;
const TRIGGER_HEIGHT = 32;
const Y_OFFSET = 8;

export const useEmojiPicker = ({wrapperRef, onEmojiPicked}: EmojiPickerParams) => {
const [open, setOpen] = useState(false);

Expand All @@ -43,7 +47,7 @@ export const useEmojiPicker = ({wrapperRef, onEmojiPicked}: EmojiPickerParams) =
const handleToggle = (event: MouseEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
// eslint-disable-next-line id-length
emojiPickerPosition.current = {x: rect.x, y: rect.y};
emojiPickerPosition.current = {x: rect.x + TRIGGER_WIDTH, y: rect.y - TRIGGER_HEIGHT - Y_OFFSET};
setOpen(prev => !prev);
};

Expand Down
27 changes: 20 additions & 7 deletions src/script/components/RichTextEditor/RichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {ReactElement, useRef} from 'react';
import {CodeHighlightNode, CodeNode} from '@lexical/code';
import {LinkNode} from '@lexical/link';
import {ListItemNode, ListNode} from '@lexical/list';
import {$convertToMarkdownString, TRANSFORMERS} from '@lexical/markdown';
import {$convertToMarkdownString} from '@lexical/markdown';
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
import {InitialConfigType, LexicalComposer} from '@lexical/react/LexicalComposer';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
Expand Down Expand Up @@ -52,9 +52,12 @@ import {EmojiPickerPlugin} from './plugins/EmojiPickerPlugin';
import {GlobalEventsPlugin} from './plugins/GlobalEventsPlugin';
import {HistoryPlugin} from './plugins/HistoryPlugin';
import {findAndTransformEmoji, ReplaceEmojiPlugin} from './plugins/InlineEmojiReplacementPlugin';
import {ListItemTabIndentationPlugin} from './plugins/ListIndentationPlugin/ListIndentationPlugin';
import {ListMaxIndentLevelPlugin} from './plugins/ListMaxIndentLevelPlugin/ListMaxIndentLevelPlugin';
import {MentionsPlugin} from './plugins/MentionsPlugin';
import {ReplaceCarriageReturnPlugin} from './plugins/ReplaceCarriageReturnPlugin/ReplaceCarriageReturnPlugin';
import {SendPlugin} from './plugins/SendPlugin';
import {markdownTransformers} from './utils/markdownTransformers';

import {MentionEntity} from '../../message/MentionEntity';

Expand All @@ -75,8 +78,13 @@ const theme = {
code: 'editor-code',
},
list: {
ul: 'editor-list editor-list--unordered',
ol: 'editor-list editor-list--ordered',
ul: 'editor-list editor-list-unordered',
ol: 'editor-list editor-list-ordered',
listitem: 'editor-list__item',
nested: {
listitem: 'editor-list__item--nested',
},
olDepth: ['editor-list-ordered--1', 'editor-list-ordered--2', 'editor-list-ordered--3'],
},
heading: {
h1: 'editor-heading editor-heading--1',
Expand Down Expand Up @@ -187,7 +195,7 @@ export const RichTextEditor = ({
return;
}

const markdown = $convertToMarkdownString(TRANSFORMERS);
const markdown = $convertToMarkdownString(markdownTransformers);

onUpdate({
text: replaceEmojis ? findAndTransformEmoji(markdown) : markdown,
Expand All @@ -210,14 +218,15 @@ export const RichTextEditor = ({
/>
<DraftStatePlugin loadDraftState={loadDraftState} />
<EditedMessagePlugin message={editedMessage} />

<ListItemTabIndentationPlugin />
<ListMaxIndentLevelPlugin maxDepth={3} />
<EmojiPickerPlugin openStateRef={emojiPickerOpen} />
<HistoryPlugin />
<ListPlugin />
{replaceEmojis && <ReplaceEmojiPlugin />}

<ReplaceCarriageReturnPlugin />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
<MarkdownShortcutPlugin transformers={markdownTransformers} />

<RichTextPlugin
contentEditable={<ContentEditable className="conversation-input-bar-text" data-uie-name="input-message" />}
Expand All @@ -242,7 +251,11 @@ export const RichTextEditor = ({
/>
</div>
</div>
{showFormatToolbar && <FormatToolbar />}
{showFormatToolbar && (
<div className="input-bar-toolbar">
<FormatToolbar />
</div>
)}
{children}
</LexicalComposer>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,4 @@ import {CSSObject} from '@emotion/react';
export const wrapperStyles: CSSObject = {
display: 'flex',
alignItems: 'center',
margin: '8px 0 8px auto',
gridArea: 'toolbar',
};
Original file line number Diff line number Diff line change
Expand Up @@ -77,18 +77,18 @@ export const FormatToolbar = () => {
active={activeFormats.includes('strikethrough')}
onClick={() => formatText('strikethrough')}
/>
<FormatButton
label={t('richTextUnorderedList')}
icon={BulletListIcon}
active={activeFormats.includes('unorderedList')}
onClick={() => toggleList('unordered')}
/>
<FormatButton
label={t('richTextOrderedList')}
icon={NumberedListIcon}
active={activeFormats.includes('orderedList')}
onClick={() => toggleList('ordered')}
/>
<FormatButton
label={t('richTextUnorderedList')}
icon={BulletListIcon}
active={activeFormats.includes('unorderedList')}
onClick={() => toggleList('unordered')}
/>
<FormatButton
label={t('richTextCode')}
icon={CodeIcon}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
import {useEffect} from 'react';

import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$getSelection, $isRangeSelection, $createParagraphNode, createCommand} from 'lexical';
import {$createHeadingNode} from '@lexical/rich-text';
import {$setBlocksType} from '@lexical/selection';
import {$getSelection, createCommand, $isRangeSelection, $createParagraphNode} from 'lexical';

import {headingCommand} from './headingCommand';

Expand All @@ -46,20 +48,12 @@ export const useHeadingState = () => {
const anchorNode = selection.anchor.getNode();
const isHeading = isNodeHeading(anchorNode);

if (!isHeading) {
editor.dispatchCommand(INSERT_HEADING_COMMAND, {});
if (isHeading) {
$setBlocksType(selection, () => $createParagraphNode());
return;
}

const paragraphNode = $createParagraphNode();
const headingNode = anchorNode.getParent();

if (!headingNode) {
return;
}

headingNode.replace(paragraphNode);
paragraphNode.append(...headingNode.getChildren());
$setBlocksType(selection, () => $createHeadingNode('h1'));
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ import {t} from 'Util/LocalizerUtil';
interface SendMessageButtonProps {
disabled?: boolean;
onSend: () => void;
className?: string;
}

export const SendMessageButton = ({disabled, onSend}: SendMessageButtonProps) => {
export const SendMessageButton = ({disabled, onSend, className}: SendMessageButtonProps) => {
return (
<button
type="button"
className={cx('controls-right-button controls-right-button--send')}
className={cx('controls-right-button controls-right-button--send', className)}
disabled={disabled}
title={t('tooltipConversationSendMessage')}
aria-label={t('tooltipConversationSendMessage')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@

import {useEffect} from 'react';

import {$convertFromMarkdownString, TRANSFORMERS} from '@lexical/markdown';
import {$convertFromMarkdownString} from '@lexical/markdown';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$getRoot, $setSelection} from 'lexical';

import {markdownTransformers} from 'Components/RichTextEditor/utils/markdownTransformers';
import {ContentMessage} from 'src/script/entity/message/ContentMessage';

import {getMentionMarkdownTransformer} from './getMentionMarkdownTransformer/getMentionMarkdownTransformer';
import {getMentionNodesFromMessage} from './getMentionNodesFromMessage/getMentionNodesFromMessage';
import {wrapMentionsWithTags} from './wrapMentionsWithTags/wrapMentionsWithTags';

type Props = {
message?: ContentMessage;
Expand All @@ -51,14 +53,16 @@ export function EditedMessagePlugin({message}: Props): null {

const allowedMentions = mentionNodes.map(node => node.getTextContent());

const wrappedWithTags = wrapMentionsWithTags(messageContent, allowedMentions);

const mentionMarkdownTransformer = getMentionMarkdownTransformer(allowedMentions);

// Text comes from the message is in the raw markdown format, we need to convert it to the editor format (preview), display **bold** as bold, etc.
// The below function do that by getting the text, and transofrming it to the desired format.
// During the transformation, we have to tell the editor to transofrm mentions as well.
// We can't do that by diretcly updating the $root (e.g. $root.appent(...MentionNodes)), because this function will overwrite the result.
// One way of overcoming this issue is to use a custom transformer (quite a hacky way). Transformers are responisble for converting the text to the desired format (e.g. **bold** to bold).
$convertFromMarkdownString(messageContent, [...TRANSFORMERS, mentionMarkdownTransformer]);
$convertFromMarkdownString(wrappedWithTags, [mentionMarkdownTransformer, ...markdownTransformers]);

editor.focus();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,26 @@ import {$createMentionNode, $isMentionNode, MentionNode} from 'Components/RichTe

// Cutom transformer for handling mentions when converting markdown to editor format.
// Based on https://github.com/facebook/lexical/blob/main/packages/lexical-markdown/src/MarkdownTransformers.ts#L489
// It takes mentions from the markdown (e.g. <mention>@John Doe</mention>) and converts them to MentionNodes.
export const getMentionMarkdownTransformer = (allowedMentions: Array<string>): TextMatchTransformer => {
return {
dependencies: [MentionNode],
export: node => {
if (!$isMentionNode(node)) {
return null;
}
return `@${node.getTextContent()}`;
return `<mention>${node.getTextContent()}</mention>`;
},
importRegExp: /(@\w+)/,
regExp: /(@\w+)$/,
importRegExp: /<mention>([^<]+)<\/mention>/,
regExp: /<mention>([^<]+)<\/mention>/,
replace: (textNode, match) => {
const [mentionText] = match;
const mentionText = match[1];

if (!allowedMentions.includes(mentionText)) {
return;
}

const mentionNode = $createMentionNode('@', mentionText.slice(1));

textNode.replace(mentionNode);
},
trigger: ' ',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

/**
* Wraps mentions in a given text with a <mention> tag.
* It's useful cause it sets the mentions apart from the rest of the text.
* Thanks to that, we can differentiate them from the rest of the text, and render them as MentionNodes.
*/
export const wrapMentionsWithTags = (text: string, allMentions: string[]): string => {
if (!allMentions.length) {
return text;
}

const mentionRegex = new RegExp(`(${allMentions.join('|')})`, 'g');

return text.replace(mentionRegex, '<mention>$1</mention>');
};
Loading
Loading