Skip to content

Commit

Permalink
feat(RichTextEditor): add support for blockquotes [WPB-12089] (#18557)
Browse files Browse the repository at this point in the history
* feat(RichTextEditor): add support for blockquotes

* feat(input-bar): reset margin for blockquote styling

* test(messageRenderer): remove blockquote rendering test cases

* feat(RichTextEditor): add BlockquotePlugin to support multiline quotes

* revert: SendPlugin changes

* fix(BlockquotePlugin): update return type to null for consistency

* fix(BlockquotePlugin): correct line break command handling in blockquote

* feat(RichTextEditor): reorder plugins for conditional rendering of List and Blockquote plugins
  • Loading branch information
olafsulich authored Jan 8, 2025
1 parent bb18406 commit 23f1db1
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 9 deletions.
10 changes: 6 additions & 4 deletions src/script/components/RichTextEditor/RichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {FormatToolbar} from './components/FormatToolbar/FormatToolbar';
import {EmojiNode} from './nodes/EmojiNode';
import {MentionNode} from './nodes/MentionNode';
import {AutoFocusPlugin} from './plugins/AutoFocusPlugin';
import {BlockquotePlugin} from './plugins/BlockquotePlugin/BlockquotePlugin';
import {CodeHighlightPlugin} from './plugins/CodeHighlightPlugin/CodeHighlightPlugin';
import {DraftStatePlugin} from './plugins/DraftStatePlugin';
import {EditedMessagePlugin} from './plugins/EditedMessagePlugin/EditedMessagePlugin';
Expand Down Expand Up @@ -78,6 +79,7 @@ const theme = {
strikethrough: 'editor-strikethrough',
code: 'editor-inline-code',
},
quote: 'editor-quote',
list: {
ul: 'editor-list editor-list-unordered',
ol: 'editor-list editor-list-ordered',
Expand Down Expand Up @@ -197,6 +199,7 @@ const editorConfig: InitialConfigType = {
CodeNode,
CodeHighlightNode,
LinkNode,
QuoteNode,
],
};

Expand Down Expand Up @@ -256,17 +259,18 @@ export const RichTextEditor = ({
<EditedMessagePlugin message={editedMessage} showMarkdownPreview={showMarkdownPreview} />
<EmojiPickerPlugin openStateRef={emojiPickerOpen} />
<HistoryPlugin />
<ListPlugin />
{replaceEmojis && <ReplaceEmojiPlugin />}

{replaceEmojis && <ReplaceEmojiPlugin />}
<ReplaceCarriageReturnPlugin />

{showMarkdownPreview && (
<>
<ListPlugin />
<ListItemTabIndentationPlugin />
<ListMaxIndentLevelPlugin maxDepth={3} />
<MarkdownShortcutPlugin transformers={markdownTransformers} />
<CodeHighlightPlugin />
<BlockquotePlugin />
</>
)}

Expand All @@ -275,13 +279,11 @@ export const RichTextEditor = ({
placeholder={<Placeholder text={placeholder} hasLocalEphemeralTimer={hasLocalEphemeralTimer} />}
ErrorBoundary={LexicalErrorBoundary}
/>

<ClearEditorPlugin />
<MentionsPlugin
onSearch={search => (typeof search === 'string' ? getMentionCandidates(search) : [])}
openStateRef={mentionsOpen}
/>

<OnChangePlugin onChange={handleChange} ignoreSelectionChange />
<SendPlugin
onSend={() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Wire
* Copyright (C) 2025 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/.
*
*/

import {useEffect} from 'react';

import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$isQuoteNode} from '@lexical/rich-text';
import {
COMMAND_PRIORITY_LOW,
KEY_ENTER_COMMAND,
$getSelection,
$isRangeSelection,
KEY_BACKSPACE_COMMAND,
$isLineBreakNode,
INSERT_PARAGRAPH_COMMAND,
LexicalEditor,
INSERT_LINE_BREAK_COMMAND,
} from 'lexical';

export const BlockquotePlugin = (): null => {
const [editor] = useLexicalComposerContext();

useEffect(() => {
return registerBlockquoteEnterCommand(editor);
}, [editor]);

useEffect(() => {
return registerBlockquoteBackspaceCommand(editor);
}, [editor]);

return null;
};

/**
* Because we use a custom Shift + Enter command (see SendPlugin.tsx), we need to register a custom Shify + Enter command for the blockquote.
* By default our Shift + Enter adds a new paragraph, which escapes the blockquote, prevents for adding multiline quotes.
* This command will add a new line break instead of a new paragraph, which will keep the blockquote.
*/
const registerBlockquoteEnterCommand = (editor: LexicalEditor) => {
return editor.registerCommand(
KEY_ENTER_COMMAND,
event => {
if (!event) {
return false;
}

const selection = $getSelection();

if (!$isRangeSelection(selection)) {
return false;
}

const anchorNode = selection.anchor.getNode();
const quoteBlock = anchorNode.getParent();

if (!$isQuoteNode(quoteBlock) && !$isQuoteNode(anchorNode)) {
return false;
}

event.preventDefault();

if (event.shiftKey) {
editor.update(() => {
editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
});
}

return true;
},
COMMAND_PRIORITY_LOW,
);
};

/**
* Because we use a custom Shift + Enter for the blockquotes, we no longer have an abilitiy to escape a blockquote by pressing Shift + Enter (cause the above command adds a new line break).
* This command will remove the last line break in the blockquote and add a new paragraph, which will escape the blockquote.
*/
const registerBlockquoteBackspaceCommand = (editor: LexicalEditor) => {
return editor.registerCommand(
KEY_BACKSPACE_COMMAND,
event => {
const selection = $getSelection();

if (!$isRangeSelection(selection)) {
return false;
}

event.preventDefault();

const anchorNode = selection.anchor.getNode();
const quoteBlock = anchorNode.getParent();

if (!$isQuoteNode(quoteBlock) && !$isQuoteNode(anchorNode)) {
return false;
}

if (!('getChildren' in anchorNode)) {
return false;
}

const children = anchorNode.getChildren();

const lastChild = children?.[children.length - 1];

const isLastChildLineBreakNode = $isLineBreakNode(lastChild);

if (!isLastChildLineBreakNode) {
return false;
}

editor.update(() => {
lastChild.remove();
editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
});

return true;
},
COMMAND_PRIORITY_LOW,
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
ORDERED_LIST,
STRIKETHROUGH,
UNORDERED_LIST,
QUOTE,
} from '@lexical/markdown';

export const markdownTransformers = [
Expand All @@ -39,4 +40,5 @@ export const markdownTransformers = [
INLINE_CODE,
ITALIC_STAR,
STRIKETHROUGH,
QUOTE,
];
5 changes: 0 additions & 5 deletions src/script/util/messageRenderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,11 +552,6 @@ describe('Ignored Markdown syntax', () => {
expect(renderMessage('no h2\n---')).toBe('no h2<br>---');
});

it('does not render blockquotes', () => {
expect(renderMessage('>no blockquote')).toBe('&gt;no blockquote');
expect(renderMessage('> no blockquote')).toBe('&gt; no blockquote');
});

it('does not render tables', () => {
const input = 'First Header | Second Header\n------------ | -------------\nCell 1 | Cell 2';
const expected = 'First Header | Second Header<br>------------ | -------------<br>Cell 1 | Cell 2';
Expand Down
4 changes: 4 additions & 0 deletions src/script/util/messageRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const markdownit = new MarkdownIt('zero', {
'newline',
'list',
'strikethrough',
'blockquote',
]);

const originalFenceRule = markdownit.renderer.rules.fence!;
Expand Down Expand Up @@ -84,6 +85,9 @@ markdownit.normalizeLink = (url: string): string => {
return url;
};

markdownit.renderer.rules.blockquote_open = () => '<blockquote class="md-blockquote">';
markdownit.renderer.rules.blockquote_close = () => '</blockquote>';

markdownit.renderer.rules.softbreak = () => '<br>';
markdownit.renderer.rules.hardbreak = () => '<br>';
markdownit.renderer.rules.paragraph_open = (tokens, idx) => {
Expand Down
19 changes: 19 additions & 0 deletions src/style/components/lexical-input.less
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,25 @@
}
}

.editor-quote {
position: relative;
padding: 0 12px 0 16px;
margin: 0;
margin-left: 2px;

&::before {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
border-radius: 2px;
margin-right: 12px;
background-color: var(--message-quote-bg);
content: '';
}
}

// Lexical code highlighting

// Prism One Light Theme - based on node_modules/prism-themes/themes/prism-one-light.css
Expand Down
4 changes: 4 additions & 0 deletions src/style/content/conversation/input-bar.less
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,10 @@
padding: 0 16px;
margin: 0;
}

blockquote {
margin: 0;
}
}

.md-heading {
Expand Down
18 changes: 18 additions & 0 deletions src/style/content/conversation/message-list.less
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,24 @@
display: none;
}
}

.md-blockquote {
position: relative;
padding: 0 12px 0 16px;
margin: 0;

&::before {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
border-radius: 2px;
margin-right: 12px;
background-color: var(--message-quote-bg);
content: '';
}
}
}

.message-body-like {
Expand Down

0 comments on commit 23f1db1

Please sign in to comment.