diff --git a/src/script/components/RichTextEditor/RichTextEditor.tsx b/src/script/components/RichTextEditor/RichTextEditor.tsx index 6f9c6a2f24e..a8492aa3c11 100644 --- a/src/script/components/RichTextEditor/RichTextEditor.tsx +++ b/src/script/components/RichTextEditor/RichTextEditor.tsx @@ -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'; @@ -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', @@ -197,6 +199,7 @@ const editorConfig: InitialConfigType = { CodeNode, CodeHighlightNode, LinkNode, + QuoteNode, ], }; @@ -256,17 +259,18 @@ export const RichTextEditor = ({ - - {replaceEmojis && } + {replaceEmojis && } {showMarkdownPreview && ( <> + + )} @@ -275,13 +279,11 @@ export const RichTextEditor = ({ placeholder={} ErrorBoundary={LexicalErrorBoundary} /> - (typeof search === 'string' ? getMentionCandidates(search) : [])} openStateRef={mentionsOpen} /> - { diff --git a/src/script/components/RichTextEditor/plugins/BlockquotePlugin/BlockquotePlugin.tsx b/src/script/components/RichTextEditor/plugins/BlockquotePlugin/BlockquotePlugin.tsx new file mode 100644 index 00000000000..1484594d9aa --- /dev/null +++ b/src/script/components/RichTextEditor/plugins/BlockquotePlugin/BlockquotePlugin.tsx @@ -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, + ); +}; diff --git a/src/script/components/RichTextEditor/utils/markdownTransformers.ts b/src/script/components/RichTextEditor/utils/markdownTransformers.ts index 8c1cf0bf221..9013f9ea212 100644 --- a/src/script/components/RichTextEditor/utils/markdownTransformers.ts +++ b/src/script/components/RichTextEditor/utils/markdownTransformers.ts @@ -27,6 +27,7 @@ import { ORDERED_LIST, STRIKETHROUGH, UNORDERED_LIST, + QUOTE, } from '@lexical/markdown'; export const markdownTransformers = [ @@ -39,4 +40,5 @@ export const markdownTransformers = [ INLINE_CODE, ITALIC_STAR, STRIKETHROUGH, + QUOTE, ]; diff --git a/src/script/util/messageRenderer.test.ts b/src/script/util/messageRenderer.test.ts index b1d20a1bb1c..2934207b676 100644 --- a/src/script/util/messageRenderer.test.ts +++ b/src/script/util/messageRenderer.test.ts @@ -552,11 +552,6 @@ describe('Ignored Markdown syntax', () => { expect(renderMessage('no h2\n---')).toBe('no h2
---'); }); - it('does not render blockquotes', () => { - expect(renderMessage('>no blockquote')).toBe('>no blockquote'); - expect(renderMessage('> no blockquote')).toBe('> no blockquote'); - }); - it('does not render tables', () => { const input = 'First Header | Second Header\n------------ | -------------\nCell 1 | Cell 2'; const expected = 'First Header | Second Header
------------ | -------------
Cell 1 | Cell 2'; diff --git a/src/script/util/messageRenderer.ts b/src/script/util/messageRenderer.ts index 9ddcb316658..90594a11159 100644 --- a/src/script/util/messageRenderer.ts +++ b/src/script/util/messageRenderer.ts @@ -56,6 +56,7 @@ const markdownit = new MarkdownIt('zero', { 'newline', 'list', 'strikethrough', + 'blockquote', ]); const originalFenceRule = markdownit.renderer.rules.fence!; @@ -84,6 +85,9 @@ markdownit.normalizeLink = (url: string): string => { return url; }; +markdownit.renderer.rules.blockquote_open = () => '
'; +markdownit.renderer.rules.blockquote_close = () => '
'; + markdownit.renderer.rules.softbreak = () => '
'; markdownit.renderer.rules.hardbreak = () => '
'; markdownit.renderer.rules.paragraph_open = (tokens, idx) => { diff --git a/src/style/components/lexical-input.less b/src/style/components/lexical-input.less index 5240d8264b3..ad2e947f38a 100644 --- a/src/style/components/lexical-input.less +++ b/src/style/components/lexical-input.less @@ -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 diff --git a/src/style/content/conversation/input-bar.less b/src/style/content/conversation/input-bar.less index b46fde823d1..2f48807c685 100644 --- a/src/style/content/conversation/input-bar.less +++ b/src/style/content/conversation/input-bar.less @@ -351,6 +351,10 @@ padding: 0 16px; margin: 0; } + + blockquote { + margin: 0; + } } .md-heading { diff --git a/src/style/content/conversation/message-list.less b/src/style/content/conversation/message-list.less index 7d930ea6e9b..a1a7b070a68 100644 --- a/src/style/content/conversation/message-list.less +++ b/src/style/content/conversation/message-list.less @@ -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 {