feat(app): edit blockquote lines in the composer (#2676)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-11 13:27:50 +02:00
1 parent a98d8ef679
commit bcef7b3123
21 files changed
+2037 -42

No files matched your search

@@ -19,6 +19,7 @@ import Emoji from '@app/features/emoji/state/Emoji';
import {checkEmojiAvailability} from '@app/features/expressions/utils/ExpressionPermissionUtils';
import Guilds from '@app/features/guild/state/Guilds';
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import GuildMembers from '@app/features/member/state/GuildMembers';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
@@ -193,7 +194,7 @@ export const UserMessage = observer(() => {
if (message.messageSnapshots) {
return;
}
const content = (actualContent ?? '').trim();
const content = dropTrailingEmptyBlockquoteLines(actualContent ?? '').trim();
if (!content) {
if (canSubmitEmptyMessageEdit(message)) {
if (message.content.length === 0) {
@@ -9,6 +9,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {convertMarkdownToSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
@@ -145,7 +146,7 @@ export const ChannelOverviewTopicEditor = observer(
});
}, [actualTopic, form, isTopicInitialized]);
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
setActualTopic(wire);
setActualTopic(dropTrailingEmptyBlockquoteLines(wire));
}, []);
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
const composer = composerRef.current;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,300 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type BlockquoteEdit,
type BlockquoteLine,
planBlockquoteArrowLeft,
planBlockquoteBackspace,
planBlockquoteLineBreak,
resolveBlockquoteCaret,
} from '@app/features/lexical/composer/blockquoteLines';
import {
$captureSelectionOffsets,
$getComposerBlockquoteState,
$replaceComposerRange,
$selectComposerOffset,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {
$createComposerBlockquoteLineNode,
$getComposerLineNodes,
$isComposerBlockquoteLineNode,
} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {$isComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
import {$isComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
import {$isComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
import {mergeRegister} from '@lexical/utils';
import {
$createTextNode,
$isLineBreakNode,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_HIGH,
DELETE_CHARACTER_COMMAND,
type ElementNode,
INSERT_LINE_BREAK_COMMAND,
INSERT_PARAGRAPH_COMMAND,
KEY_ARROW_LEFT_COMMAND,
type LexicalEditor,
type LexicalNode,
type LineBreakNode,
SELECTION_CHANGE_COMMAND,
TextNode,
} from 'lexical';
export interface ComposerLine {
nodes: Array<LexicalNode>;
lineBreak: LineBreakNode | null;
}
type BlockquotePlanner = (text: string, lines: ReadonlyArray<BlockquoteLine>, caret: number) => BlockquoteEdit | null;
export function $splitComposerLines(paragraph: ElementNode): Array<ComposerLine> {
const lines: Array<ComposerLine> = [{nodes: [], lineBreak: null}];
for (const node of $getComposerLineNodes(paragraph)) {
if ($isLineBreakNode(node)) {
lines.push({nodes: [], lineBreak: node});
} else {
lines[lines.length - 1]!.nodes.push(node);
}
}
return lines;
}
function $applyBlockquotePlan(editor: LexicalEditor, planner: BlockquotePlanner): boolean {
if (editor.isComposing()) {
return false;
}
const {scanText, selection, lines} = $getComposerBlockquoteState();
if (selection == null || selection.anchor !== selection.focus || lines.length === 0) {
return false;
}
const edit = planner(scanText, lines, selection.anchor);
if (edit == null) {
return false;
}
$replaceComposerRange(edit.start, edit.end, {kind: 'text', text: edit.text}, {leading: false, trailing: false});
$selectComposerOffset(edit.caret);
return true;
}
export function $snapCaretOutOfBlockquoteMarker(): void {
const {selection, lines} = $getComposerBlockquoteState();
if (selection == null || selection.anchor !== selection.focus || lines.length === 0) {
return;
}
const caret = resolveBlockquoteCaret(lines, selection.anchor);
if (caret !== selection.anchor) {
$selectComposerOffset(caret);
}
}
function shouldWrapLine(line: ComposerLine, quoted: boolean | undefined): boolean {
return quoted === true && $isComposerBlockquoteMarkerNode(line.nodes[0]);
}
function $blockquoteStructureMatches(
paragraph: ElementNode,
lines: ReadonlyArray<ComposerLine>,
quoted: ReadonlyArray<boolean>,
): boolean {
const children = paragraph.getChildren();
let index = 0;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex]!;
if (line.lineBreak != null) {
if (!line.lineBreak.is(children[index])) {
return false;
}
index += 1;
}
if (shouldWrapLine(line, quoted[lineIndex])) {
const wrapper = children[index];
index += 1;
if (!$isComposerBlockquoteLineNode(wrapper)) {
return false;
}
const inner = wrapper.getChildren();
if (inner.length !== line.nodes.length || inner.some((node, position) => !node.is(line.nodes[position]))) {
return false;
}
continue;
}
for (const node of line.nodes) {
if (!node.is(children[index])) {
return false;
}
index += 1;
}
}
return index === children.length;
}
export function $syncComposerBlockquoteLines(paragraph: ElementNode, quoted: ReadonlyArray<boolean>): void {
if ($blockquoteStructureMatches(paragraph, $splitComposerLines(paragraph), quoted)) {
return;
}
const selection = $captureSelectionOffsets();
for (const child of paragraph.getChildren()) {
if ($isComposerBlockquoteLineNode(child)) {
for (const node of $getComposerLineNodes(child)) {
child.insertBefore(node);
}
child.remove();
}
}
$splitComposerLines(paragraph).forEach((line, index) => {
if (shouldWrapLine(line, quoted[index])) {
const wrapper = $createComposerBlockquoteLineNode();
line.nodes[0]!.insertBefore(wrapper);
wrapper.append(...line.nodes);
}
});
if (selection != null) {
$selectComposerRange(selection.anchor, selection.focus);
}
}
function isPlainTextLeaf(node: LexicalNode | undefined): node is TextNode {
return node instanceof TextNode && !$isComposerPlainSegmentNode(node) && !$isComposerCommandNode(node);
}
function $spliceLeadingText(
nodes: ReadonlyArray<LexicalNode>,
offset: number,
deleteCount: number,
text: string,
): boolean {
const covered: Array<TextNode> = [];
let length = 0;
for (const node of nodes) {
if (!isPlainTextLeaf(node) || length >= offset + deleteCount) {
break;
}
covered.push(node);
length += node.getTextContentSize();
}
const first = covered[0];
if (first == null || length < offset + deleteCount) {
return false;
}
const combined = covered.map((node) => node.getTextContent()).join('');
first.setTextContent(`${combined.slice(0, offset)}${text}${combined.slice(offset + deleteCount)}`);
for (const node of covered.slice(1)) {
node.remove();
}
return true;
}
function $prefixComposerLine(line: ComposerLine): void {
const first = line.nodes[0];
if (isPlainTextLeaf(first)) {
first.setTextContent(`> ${first.getTextContent()}`);
} else if (first != null) {
first.insertBefore($createTextNode('> '));
} else if (line.lineBreak != null) {
line.lineBreak.insertAfter($createTextNode('> '));
}
}
export function $rewriteMultilineBlockquoteMarker(
lines: ReadonlyArray<ComposerLine>,
lineSources: ReadonlyArray<string>,
markerEnds: ReadonlyArray<number>,
): boolean {
const index = markerEnds.findIndex(
(end, lineIndex) => end > 0 && lineSources[lineIndex]!.slice(0, end).trimStart().startsWith('>>> '),
);
if (index < 0) {
return false;
}
const indent = markerEnds[index]! - 4;
const lineStarts: Array<number> = [];
let offset = 0;
for (const line of lines) {
lineStarts.push(offset);
offset += line.nodes.reduce((sum, node) => sum + node.getTextContentSize(), 0) + 1;
}
const selection = $captureSelectionOffsets();
const markerStart = lineStarts[index]! + indent;
const typed =
selection != null &&
selection.anchor === selection.focus &&
selection.anchor > markerStart &&
selection.anchor <= markerStart + 4;
if (!$spliceLeadingText(lines[index]!.nodes, indent, 4, '> ')) {
return false;
}
const prefixedStarts = typed ? [] : lineStarts.slice(index + 1);
if (!typed) {
for (const line of lines.slice(index + 1)) {
$prefixComposerLine(line);
}
}
if (selection != null) {
const map = (value: number): number => {
if (value > markerStart && value < markerStart + 4) {
return markerStart + 2;
}
let next = value >= markerStart + 4 ? value - 2 : value;
for (const start of prefixedStarts) {
if (value >= start) {
next += 2;
}
}
return next;
};
$selectComposerRange(map(selection.anchor), map(selection.focus));
}
return true;
}
export function registerComposerBlockquote(editor: LexicalEditor): () => void {
return mergeRegister(
editor.registerCommand(
INSERT_LINE_BREAK_COMMAND,
(selectStart) => !selectStart && $applyBlockquotePlan(editor, planBlockquoteLineBreak),
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
INSERT_PARAGRAPH_COMMAND,
() => $applyBlockquotePlan(editor, planBlockquoteLineBreak),
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
DELETE_CHARACTER_COMMAND,
(isBackward) => isBackward && $applyBlockquotePlan(editor, planBlockquoteBackspace),
COMMAND_PRIORITY_CRITICAL,
),
editor.registerCommand(
KEY_ARROW_LEFT_COMMAND,
(event) => {
if (event.shiftKey || event.metaKey || isIMEComposing(event) || editor.isComposing()) {
return false;
}
const {selection, lines} = $getComposerBlockquoteState();
if (selection == null || selection.anchor !== selection.focus) {
return false;
}
const caret = planBlockquoteArrowLeft(lines, selection.anchor);
if (caret == null) {
return false;
}
event.preventDefault();
$selectComposerOffset(caret);
return true;
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
if (!editor.isComposing()) {
$snapCaretOutOfBlockquoteMarker();
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
);
}
@@ -7,6 +7,7 @@ import {
$selectComposerNodeBoundary,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {$createComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {
$createComposerStandardEmojiNode,
@@ -72,7 +73,7 @@ export function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiRes
return;
}
const parent = node.getParent();
if (parent == null || parent.getType() !== 'paragraph') {
if (parent == null || (parent.getType() !== 'paragraph' && !$isComposerBlockquoteLineNode(parent))) {
return;
}
const text = node.getTextContent();
@@ -1,5 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {findBlockquoteMarkerEnds} from '@app/features/lexical/composer/blockquoteLines';
import {
$rewriteMultilineBlockquoteMarker,
$snapCaretOutOfBlockquoteMarker,
$splitComposerLines,
$syncComposerBlockquoteLines,
} from '@app/features/lexical/composer/ComposerBlockquote';
import {$captureSelectionOffsets, $selectComposerRange} from '@app/features/lexical/composer/composerOffsets';
import {
computeMarkdownHighlightSpans,
@@ -8,6 +15,11 @@ import {
type MarkdownSpan,
markSilentMessagePrefix,
} from '@app/features/lexical/composer/markdownSpans';
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {
$createComposerBlockquoteMarkerNode,
$isComposerBlockquoteMarkerNode,
} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
import {$isComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
import {$isComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {
@@ -24,7 +36,6 @@ import {
$createTextNode,
$getNodeByKey,
$getSelection,
$isLineBreakNode,
$isNodeSelection,
$isRangeSelection,
$setSelection,
@@ -42,7 +53,6 @@ const STYLE_BY_BIT: ReadonlyArray<{bit: number; style: string}> = [
style: 'background-color:var(--background-modifier-active,rgba(0,0,0,0.15));border-radius:0.1875rem',
},
{bit: MarkdownHl.heading, style: 'font-weight:700'},
{bit: MarkdownHl.blockquote, style: 'color:var(--text-secondary)'},
{bit: MarkdownHl.subtext, style: 'font-size:0.85em;color:var(--text-muted,var(--text-secondary))'},
{bit: MarkdownHl.link, style: 'color:var(--text-link)'},
{bit: MarkdownHl.codeBlock, style: 'font-size:0.75em'},
@@ -98,6 +108,7 @@ export function registerComposerMarkdownHighlight(
type Desired =
| {role: 'marker'; text: string}
| {role: 'quote'; text: string}
| {role: 'content'; text: string; format: MarkdownHlFormat}
| {role: 'keep'; node: LexicalNode};
@@ -105,31 +116,26 @@ type BuildableDesired = Exclude<Desired, {role: 'keep'}>;
export function $reconcileLineOf(node: TextNode, parserFlags?: number): void {
const parent = node.getParent();
if (parent == null || parent.getType() !== 'paragraph') {
const block = $isComposerBlockquoteLineNode(parent) ? parent.getParent() : parent;
if (block == null || block.getType() !== 'paragraph') {
return;
}
$reconcileParagraph(parent as ParagraphNode, parserFlags);
$reconcileParagraph(block as ParagraphNode, parserFlags);
}
function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, silentMessagePrefix = false): void {
const lines: Array<Array<LexicalNode>> = [];
let line: Array<LexicalNode> = [];
for (const child of paragraph.getChildren()) {
if ($isLineBreakNode(child)) {
lines.push(line);
line = [];
} else {
line.push(child);
}
}
lines.push(line);
const lineSources = lines.map((nodes) => nodes.map($nodeWireText).join(''));
const lines = $splitComposerLines(paragraph);
const lineSources = lines.map((line) => line.nodes.map($nodeWireText).join(''));
const source = lineSources.join('\n');
const markdownSpans = computeMarkdownHighlightSpans(source, parserFlags);
const quoteMarkerEnds = findBlockquoteMarkerEnds(source, markdownSpans);
if ($rewriteMultilineBlockquoteMarker(lines, lineSources, quoteMarkerEnds)) {
return;
}
const spans = silentMessagePrefix ? markSilentMessagePrefix(markdownSpans, source) : markdownSpans;
let lineStart = 0;
for (let index = 0; index < lines.length; index += 1) {
const nodes = lines[index]!;
const nodes = lines[index]!.nodes;
const lineSource = lineSources[index]!;
const lineEnd = lineStart + lineSource.length;
if (nodes.length > 0) {
@@ -140,10 +146,17 @@ function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, sil
end: Math.min(span.end, lineEnd) - lineStart,
}))
.filter((span) => span.end > span.start);
$reconcileLine(nodes, parserFlags, lineSource, lineSpans);
$reconcileLine(nodes, parserFlags, lineSource, lineSpans, quoteMarkerEnds[index]);
}
lineStart = lineEnd + 1;
}
$syncComposerBlockquoteLines(
paragraph,
quoteMarkerEnds.map((end) => end > 0),
);
if (quoteMarkerEnds.some((end) => end > 0)) {
$snapCaretOutOfBlockquoteMarker();
}
}
function $isEscapedAtOffset(source: string, offset: number): boolean {
@@ -161,6 +174,7 @@ function $reconcileLine(
parserFlags?: number,
precomputedSource?: string,
precomputedSpans?: Array<MarkdownSpan>,
quoteMarkerEnd = 0,
): void {
const desired: Array<Desired> = [];
const source = precomputedSource == null ? line.map($nodeWireText).join('') : precomputedSource;
@@ -210,7 +224,16 @@ function $reconcileLine(
continue;
}
const text = source.slice(start, end);
desired.push(span.role === 'marker' ? {role: 'marker', text} : {role: 'content', text, format: span.format});
const previous = desired[desired.length - 1];
if (span.role === 'marker' && span.start < quoteMarkerEnd) {
if (previous != null && previous.role === 'quote') {
previous.text += text;
} else {
desired.push({role: 'quote', text});
}
} else {
desired.push(span.role === 'marker' ? {role: 'marker', text} : {role: 'content', text, format: span.format});
}
}
} else {
desired.push({role: 'keep', node});
@@ -309,8 +332,12 @@ function $descriptorsMatch(line: Array<LexicalNode>, desired: Array<Desired>): b
if (!(node instanceof TextNode) || node.getTextContent() !== want.text) {
return false;
}
if (want.role === 'marker') {
if (!$isSyntaxMarkerNode(node)) {
if (want.role === 'quote') {
if (!$isComposerBlockquoteMarkerNode(node)) {
return false;
}
} else if (want.role === 'marker') {
if (!$isSyntaxMarkerNode(node) || $isComposerBlockquoteMarkerNode(node)) {
return false;
}
} else if ($isSyntaxMarkerNode(node) || !$contentNodeMatchesFormat(node, want.format)) {
@@ -321,6 +348,9 @@ function $descriptorsMatch(line: Array<LexicalNode>, desired: Array<Desired>): b
}
function $buildDescriptorNode(desired: BuildableDesired): TextNode {
if (desired.role === 'quote') {
return $createComposerBlockquoteMarkerNode(desired.text);
}
if (desired.role === 'marker') {
return $createSyntaxMarkerNode(desired.text);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {$getComposerLineNodes} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {
$createComposerCommandNode,
$isComposerCommandNode,
@@ -227,7 +228,7 @@ export function $projectComposer(): ComposerProjection {
wire += block.getTextContent();
continue;
}
for (const child of block.getChildren()) {
for (const child of $getComposerLineNodes(block)) {
if ($isLineBreakNode(child)) {
display += '\n';
wire += '\n';
@@ -6,6 +6,7 @@ import {
$createComposerInsertNode,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {$getComposerLineNodes} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
@@ -93,7 +94,7 @@ function $convertSpecialMention(root: RootNode, plainText: boolean): void {
source += '\n';
displayLength += 1;
}
for (const child of $isElementNode(block) ? block.getChildren() : [block]) {
for (const child of $isElementNode(block) ? $getComposerLineNodes(block) : [block]) {
if (
$isTextNode(child) &&
child.isSimpleText() &&
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AutocompleteOption, AutocompleteType} from '@app/features/channel/components/AutocompleteTypes';
import {registerComposerBlockquote} from '@app/features/lexical/composer/ComposerBlockquote';
import {registerComposerClipboardCommands} from '@app/features/lexical/composer/ComposerClipboard';
import {registerComposerCodeIndent} from '@app/features/lexical/composer/ComposerCodeIndent';
import {
@@ -40,6 +41,8 @@ import {
} from '@app/features/lexical/composer/composerOffsets';
import styles from '@app/features/lexical/composer/LexicalMessageComposer.module.css';
import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans';
import {ComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {ComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
import {ComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
import {ComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
@@ -105,6 +108,8 @@ import {useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, u
const THEME: InitialConfigType['theme'] = {
paragraph: styles.paragraph,
syntaxMarker: styles.marker,
composerBlockquoteLine: styles.blockquoteLine,
composerBlockquoteMarker: styles.blockquoteMarker,
composerMention: clsx(styles.mentionHost, markupStyles.inlineFormat),
composerCustomEmoji: styles.emojiHost,
composerCommand: styles.command,
@@ -209,6 +214,8 @@ export const LexicalComposerInput = observer((props: LexicalComposerInputProps)
SlashSeparatorNode,
SlashOptionalHintNode,
SyntaxMarkerNode,
ComposerBlockquoteLineNode,
ComposerBlockquoteMarkerNode,
],
theme: THEME,
};
@@ -517,6 +524,7 @@ const ComposerInner = ({
cleanups.push(registerSlashSlotFocus(editor, () => onSlashCommandStateChangeRef.current));
if (markdown) {
cleanups.push(registerComposerMarkdownHighlight(editor, markdownParserFlags, silentMessagePrefix));
cleanups.push(registerComposerBlockquote(editor));
}
cleanups.push(
registerComposerEmojiShortcode(editor, (shortcodeName) => {
@@ -54,6 +54,37 @@
opacity: 0.45;
}
.blockquoteLine {
position: relative;
display: inline-block;
box-sizing: border-box;
width: 100%;
min-height: var(--composer-line-height, var(--textarea-line-height, 1.375rem));
vertical-align: top;
padding-inline-start: 1rem;
color: var(--text-chat-muted);
}
.blockquoteLine::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: 0;
width: 0.25rem;
border-radius: 0.25rem;
background-color: var(--interactive-muted);
}
.marker.blockquoteMarker {
position: absolute;
inset-block-start: 0;
inset-inline-end: calc(100% - 1rem);
white-space: pre;
color: transparent;
opacity: 1;
pointer-events: none;
}
.bold {
font-weight: 700;
}
@@ -0,0 +1,185 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type BlockquoteEdit,
type BlockquoteLine,
dropTrailingEmptyBlockquoteLines,
findBlockquoteMarkerEnds,
planBlockquoteArrowLeft,
planBlockquoteBackspace,
planBlockquoteLineBreak,
resolveBlockquoteCaret,
} from '@app/features/lexical/composer/blockquoteLines';
import {computeMarkdownHighlightSpans} from '@app/features/lexical/composer/markdownSpans';
import {describe, expect, it} from 'vitest';
const QUOTE_PREFIX_RE = /^[ \t]*> /;
function quoteLines(text: string): Array<BlockquoteLine> {
const lines: Array<BlockquoteLine> = [];
let start = 0;
for (const line of text.split('\n')) {
const prefix = QUOTE_PREFIX_RE.exec(line);
if (prefix != null) {
lines.push({start, contentStart: start + prefix[0].length, end: start + line.length});
}
start += line.length + 1;
}
return lines;
}
function applyEdit(text: string, edit: BlockquoteEdit | null): string | null {
return edit == null ? null : `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`;
}
describe('planBlockquoteLineBreak', () => {
const ROWS: Array<[string, number, string, number]> = [
['> test', 6, '> test\n> ', 9],
['> test', 4, '> te\n> st', 7],
['> test', 2, '> \n> test', 5],
['> ', 2, '> \n> ', 5],
['x\n> ', 4, 'x\n> \n> ', 7],
['> test\n> ', 9, '> test\n', 7],
['> \n> ', 5, '', 0],
['> \n> \n> ', 8, '', 0],
['> a\n> \n> ', 9, '> a\n', 4],
['> a\n> \n> b', 6, '> a\n\n> b', 4],
['x\n> \n> ', 7, 'x\n', 2],
['> ', 3, '> \n> ', 6],
[' > a', 5, ' > a\n> ', 8],
];
it.each(ROWS)('on %j at %i gives %j with the caret at %i', (text, caret, expected, expectedCaret) => {
const edit = planBlockquoteLineBreak(text, quoteLines(text), caret);
expect(applyEdit(text, edit)).toBe(expected);
expect(edit?.caret).toBe(expectedCaret);
});
it.each<[string, number]>([
['abc', 3],
['', 0],
['> test', 1],
['x\n> a', 1],
])('declines on %j at %i because the caret is not on a quote line', (text, caret) => {
expect(planBlockquoteLineBreak(text, quoteLines(text), caret)).toBeNull();
});
it.each<[string, number]>([
['```\n> a', 7],
['> ```', 5],
['```\n> a\n```', 6],
])('declines on %j at %i because the caret is inside a code block', (text, caret) => {
expect(planBlockquoteLineBreak(text, quoteLines(text), caret)).toBeNull();
});
});
describe('planBlockquoteBackspace', () => {
const ROWS: Array<[string, number, string, number]> = [
['> test', 2, 'test', 0],
['> ', 2, '', 0],
['> \n> ', 5, '> \n', 3],
['> \n', 3, '', 0],
['> \nabc', 3, 'abc', 0],
['a\n> \nb', 5, 'a\nb', 2],
[' > a', 4, 'a', 0],
['x\n> test', 4, 'x\ntest', 2],
];
it.each(ROWS)('on %j at %i gives %j with the caret at %i', (text, caret, expected, expectedCaret) => {
const edit = planBlockquoteBackspace(text, quoteLines(text), caret);
expect(applyEdit(text, edit)).toBe(expected);
expect(edit?.caret).toBe(expectedCaret);
});
it.each<[string, number]>([
['> a\nb', 4],
['> test', 4],
['> ', 3],
['abc', 3],
['> \n> ', 3],
['```\n> a', 6],
])('declines on %j at %i', (text, caret) => {
expect(planBlockquoteBackspace(text, quoteLines(text), caret)).toBeNull();
});
});
describe('resolveBlockquoteCaret', () => {
it.each<[string, number, number]>([
['> test', 0, 2],
['> test', 1, 2],
['> test', 2, 2],
['> test', 5, 5],
[' > a', 3, 4],
['ab\n> cd', 3, 5],
['ab\n> cd', 4, 5],
['ab\n> cd', 1, 1],
])('maps %j at %i to %i', (text, caret, expected) => {
expect(resolveBlockquoteCaret(quoteLines(text), caret)).toBe(expected);
});
});
describe('planBlockquoteArrowLeft', () => {
it('moves to the end of the line above from a later quote line', () => {
expect(planBlockquoteArrowLeft(quoteLines('ab\n> cd'), 5)).toBe(2);
});
it('keeps the caret at content start on the first line', () => {
expect(planBlockquoteArrowLeft(quoteLines('> test'), 2)).toBe(2);
});
it.each<[string, number]>([
['> test', 4],
['> test', 0],
['abc', 2],
])('declines on %j at %i', (text, caret) => {
expect(planBlockquoteArrowLeft(quoteLines(text), caret)).toBeNull();
});
});
describe('findBlockquoteMarkerEnds', () => {
it.each<[string, Array<number>]>([
['> a', [2]],
[' > a', [4]],
['> ', [2]],
['>>> a\nb', [4, 0]],
['>>> [!NOTE]\nbody', [0, 0]],
[' >>> [!tip] x\nbody', [0, 0]],
['> > a', [2]],
['> >>> a', [2]],
['> [!NOTE]\n> body', [2, 2]],
['> a\n> b', [2, 2]],
['>a', [0]],
['\\> a', [0]],
['`> a`', [0]],
['```\n> a', [0, 0]],
['```\n> a\n```', [0, 0, 0]],
['```\nx\n```\n> a', [0, 0, 0, 2]],
])('gives %j the marker ends %j', (text, ends) => {
expect(findBlockquoteMarkerEnds(text, computeMarkdownHighlightSpans(text))).toEqual(ends);
});
it('treats whitespace after the prefix as content rather than marker', () => {
expect(computeMarkdownHighlightSpans('> ').find((span) => span.start === 2)).toMatchObject({
role: 'content',
end: 3,
});
});
});
describe('dropTrailingEmptyBlockquoteLines', () => {
it.each<[string, string]>([
['> test\n> ', '> test'],
['> ', ''],
['> test\n> \n', '> test'],
['> test\n', '> test'],
['> \n> ', ''],
['a\n > ', 'a'],
['> a\n> ', '> a'],
['> a\n>', '> a\n>'],
['```\n> ', '```\n> '],
['> a\n> b', '> a\n> b'],
['', ''],
])('turns %j into %j', (content, expected) => {
expect(dropTrailingEmptyBlockquoteLines(content)).toBe(expected);
});
});
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isOffsetInsideCodeBlock, scanCodeBlocks} from '@app/features/lexical/composer/codeBlockIndent';
import {MarkdownHl, type MarkdownSpan} from '@app/features/lexical/composer/markdownSpans';
export interface BlockquoteLine {
start: number;
contentStart: number;
end: number;
}
export interface BlockquoteEdit {
start: number;
end: number;
text: string;
caret: number;
}
const CONTINUATION = '\n> ';
const DROPPABLE_TRAILING_LINE_RE = /^[ \t]*(?:> [ \t]*)?$/;
const MULTILINE_ALERT_RE = /^[ \t]*>>> \[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i;
function isEmptyLine(line: BlockquoteLine): boolean {
return line.contentStart === line.end;
}
function isDirectlyAbove(upper: BlockquoteLine, lower: BlockquoteLine): boolean {
return upper.end + 1 === lower.start;
}
export function findBlockquoteMarkerEnds(source: string, spans: ReadonlyArray<MarkdownSpan>): Array<number> {
const markerEndByStart = new Map<number, number>();
for (const span of spans) {
if (span.role === 'marker' && (span.format & MarkdownHl.blockquoteMarker) !== 0) {
markerEndByStart.set(span.start, span.end);
}
}
const blocks = scanCodeBlocks(source);
const ends: Array<number> = [];
let lineStart = 0;
for (const line of source.split('\n')) {
const markerEnd = markerEndByStart.get(lineStart);
const insideCode = blocks.some((block) => block.start <= lineStart && lineStart <= block.end);
const suppressed = insideCode || MULTILINE_ALERT_RE.test(line);
ends.push(markerEnd == null || suppressed ? 0 : Math.min(markerEnd, lineStart + line.length) - lineStart);
lineStart += line.length + 1;
}
return ends;
}
export function planBlockquoteLineBreak(
text: string,
lines: ReadonlyArray<BlockquoteLine>,
caret: number,
): BlockquoteEdit | null {
if (isOffsetInsideCodeBlock(text, caret)) {
return null;
}
const index = lines.findIndex((line) => line.contentStart <= caret && caret <= line.end);
if (index < 0) {
return null;
}
const line = lines[index]!;
const above = index > 0 ? lines[index - 1]! : null;
if (!isEmptyLine(line) || above == null || !isDirectlyAbove(above, line)) {
return {start: caret, end: caret, text: CONTINUATION, caret: caret + CONTINUATION.length};
}
let top = index;
while (top > 0 && isDirectlyAbove(lines[top - 1]!, lines[top]!) && isEmptyLine(lines[top - 1]!)) {
top -= 1;
}
return {start: lines[top]!.start, end: line.contentStart, text: '', caret: lines[top]!.start};
}
export function planBlockquoteBackspace(
text: string,
lines: ReadonlyArray<BlockquoteLine>,
caret: number,
): BlockquoteEdit | null {
if (isOffsetInsideCodeBlock(text, caret)) {
return null;
}
const line = lines.find((candidate) => candidate.contentStart === caret);
if (line != null) {
return {start: line.start, end: line.contentStart, text: '', caret: line.start};
}
const above = lines.find((candidate) => candidate.end + 1 === caret && isEmptyLine(candidate));
if (above == null || lines.some((candidate) => candidate.start === caret)) {
return null;
}
return {start: above.start, end: caret, text: '', caret: above.start};
}
export function resolveBlockquoteCaret(lines: ReadonlyArray<BlockquoteLine>, caret: number): number {
const line = lines.find((candidate) => candidate.start <= caret && caret < candidate.contentStart);
return line == null ? caret : line.contentStart;
}
export function planBlockquoteArrowLeft(lines: ReadonlyArray<BlockquoteLine>, caret: number): number | null {
const line = lines.find((candidate) => candidate.contentStart === caret);
if (line == null) {
return null;
}
return line.start > 0 ? line.start - 1 : line.contentStart;
}
export function dropTrailingEmptyBlockquoteLines(content: string): string {
const blocks = scanCodeBlocks(content);
let end = content.length;
while (end > 0) {
const lineStart = content.lastIndexOf('\n', end - 1) + 1;
if (
!DROPPABLE_TRAILING_LINE_RE.test(content.slice(lineStart, end)) ||
blocks.some((block) => block.start <= lineStart && lineStart <= block.end)
) {
break;
}
end = Math.max(0, lineStart - 1);
}
return content.slice(0, end);
}
@@ -11,6 +11,8 @@ import {
$wrapComposerSelection,
} from '@app/features/lexical/composer/composerOffsets';
import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans';
import {ComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {ComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
import {
$isComposerCustomEmojiNode,
ComposerCustomEmojiNode,
@@ -261,7 +263,13 @@ const PARSER_ROWS: Array<[text: string, start: number, end: number, content: str
function createComposer(): LexicalEditor {
const editor = createEditor({
namespace: 'code-block-wrap-test',
nodes: [ComposerCustomEmojiNode, ComposerMentionNode, SyntaxMarkerNode],
nodes: [
ComposerBlockquoteLineNode,
ComposerBlockquoteMarkerNode,
ComposerCustomEmojiNode,
ComposerMentionNode,
SyntaxMarkerNode,
],
onError: (error) => {
throw error;
},
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {BlockquoteLine} from '@app/features/lexical/composer/blockquoteLines';
import {type CodeBlockWrapPlan, planCodeBlockWrap} from '@app/features/lexical/composer/codeBlockWrap';
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {$isComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
import {
$createComposerCustomEmojiNode,
$isComposerCustomEmojiNode,
@@ -199,6 +202,41 @@ function $captureSelectionOffsetsFromLayout(layout: DisplayLayout): ComposerSele
};
}
export interface ComposerBlockquoteState {
scanText: string;
selection: ComposerSelectionOffsets | null;
lines: Array<BlockquoteLine>;
}
function blockquoteLinesFromLayout(layout: DisplayLayout): Array<BlockquoteLine> {
const lines: Array<BlockquoteLine> = [];
for (const leaf of layout.leaves) {
const parent = leaf.node.getParent();
if (
!$isComposerBlockquoteMarkerNode(leaf.node) ||
!$isComposerBlockquoteLineNode(parent) ||
!leaf.node.is(parent.getFirstChild())
) {
continue;
}
const boundaries = layout.elementBoundaries.get(parent.getKey());
if (boundaries == null) {
continue;
}
lines.push({start: leaf.start, contentStart: leaf.end, end: boundaries[boundaries.length - 1]!});
}
return lines;
}
export function $getComposerBlockquoteState(): ComposerBlockquoteState {
const layout = $buildDisplayLayout();
return {
scanText: scanTextFromLayout(layout),
selection: $captureSelectionOffsetsFromLayout(layout),
lines: blockquoteLinesFromLayout(layout),
};
}
export function $getComposerSelectionRange(): {start: number; end: number} | null {
const offsets = $captureSelectionOffsets();
if (offsets == null) {
@@ -220,10 +258,6 @@ export function $getComposerDisplayText(): string {
return $buildDisplayLayout().text;
}
export function $getComposerScanText(): string {
return scanTextFromLayout($buildDisplayLayout());
}
function scanTextFromLayout({text, leaves}: DisplayLayout): string {
let scanText = '';
let offset = 0;
@@ -241,6 +275,10 @@ function scanTextFromLayout({text, leaves}: DisplayLayout): string {
return scanText + text.slice(offset);
}
export function $getComposerScanText(): string {
return scanTextFromLayout($buildDisplayLayout());
}
function $pointAtDisplayOffset(layout: DisplayLayout, offset: number): DisplayPoint {
const clampedOffset = Math.min(Math.max(0, offset), layout.text.length);
for (const leaf of layout.leaves) {
@@ -22,6 +22,7 @@ export const MarkdownHl = {
link: 1 << 9,
codeBlock: 1 << 10,
silent: 1 << 11,
blockquoteMarker: 1 << 12,
} as const;
export type MarkdownHlFormat = number;
@@ -793,20 +794,21 @@ function alignBlockquote(children: Array<Node>, format: MarkdownHlFormat, ctx: A
while (ctx.source[trimmedStart] === ' ' || ctx.source[trimmedStart] === '\t' || ctx.source[trimmedStart] === '\r') {
trimmedStart += 1;
}
const markerRanges: Array<{start: number; end: number}> = [];
const markerRanges: Array<{start: number; end: number; quote: boolean}> = [];
const blankRanges: Array<{start: number; end: number}> = [];
const sourceOffsets: Array<number> = [];
let virtualSource = '';
let blockEnd = start;
if (ctx.source.startsWith('>>> ', trimmedStart)) {
const contentStart = trimmedStart + 4;
markerRanges.push({start, end: contentStart});
markerRanges.push({start, end: contentStart, quote: true});
virtualSource = ctx.source.slice(contentStart);
for (let position = contentStart; position < ctx.source.length; position += 1) {
sourceOffsets.push(position);
}
blockEnd = ctx.source.length;
} else {
const lines: Array<{start: number; end: number; contentStart: number}> = [];
const lines: Array<{start: number; end: number; prefixEnd: number; contentStart: number}> = [];
let lineStart = start;
while (lineStart <= ctx.source.length) {
const newline = ctx.source.indexOf('\n', lineStart);
@@ -820,7 +822,7 @@ function alignBlockquote(children: Array<Node>, format: MarkdownHlFormat, ctx: A
}
const rest = ctx.source.slice(quoteStart + 2, lineEnd);
const contentStart = /^[ \t\r]*$/.test(rest) ? lineEnd : quoteStart + 2;
lines.push({start: lineStart, end: lineEnd, contentStart});
lines.push({start: lineStart, end: lineEnd, prefixEnd: quoteStart + 2, contentStart});
if (newline < 0) {
break;
}
@@ -832,10 +834,13 @@ function alignBlockquote(children: Array<Node>, format: MarkdownHlFormat, ctx: A
}
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!;
markerRanges.push({start: line.start, end: line.contentStart});
markerRanges.push({start: line.start, end: line.prefixEnd, quote: true});
if (line.contentStart > line.prefixEnd) {
blankRanges.push({start: line.prefixEnd, end: line.contentStart});
}
let repeatedPrefix = line.contentStart;
while (ctx.source.startsWith('> ', repeatedPrefix) && repeatedPrefix < line.end) {
markerRanges.push({start: repeatedPrefix, end: repeatedPrefix + 2});
markerRanges.push({start: repeatedPrefix, end: repeatedPrefix + 2, quote: false});
repeatedPrefix += 2;
}
for (let position = line.contentStart; position < line.end; position += 1) {
@@ -890,12 +895,18 @@ function alignBlockquote(children: Array<Node>, format: MarkdownHlFormat, ctx: A
return;
}
roles[sourceOffset - start] = span.role;
formats[sourceOffset - start] = span.format;
formats[sourceOffset - start] = span.format & ~MarkdownHl.blockquoteMarker;
}
}
for (const range of markerRanges) {
for (let position = range.start; position < range.end; position += 1) {
roles[position - start] = 'marker';
formats[position - start] = range.quote ? format | MarkdownHl.blockquoteMarker : format;
}
}
for (const range of blankRanges) {
for (let position = range.start; position < range.end; position += 1) {
roles[position - start] = 'content';
formats[position - start] = format;
}
}
@@ -933,7 +944,7 @@ function alignAlert(format: MarkdownHlFormat, ctx: AlignContext): void {
}
const newline = ctx.source.indexOf('\n', ctx.pos);
const lineEnd = newline < 0 ? ctx.source.length : newline;
pushMarker(ctx, quoteStart + 2 - ctx.pos, bodyFormat);
pushMarker(ctx, quoteStart + 2 - ctx.pos, bodyFormat | MarkdownHl.blockquoteMarker);
if (firstLine) {
const close = ctx.source.indexOf(']', ctx.pos);
if (!ctx.source.startsWith('[!', ctx.pos) || close < 0 || close >= lineEnd) {
@@ -0,0 +1,125 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
$createComposerBlockquoteLineNode,
$getComposerLineNodes,
$isComposerBlockquoteLineNode,
ComposerBlockquoteLineNode,
} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {
$createParagraphNode,
$createRangeSelection,
$createTextNode,
$getRoot,
createEditor,
type EditorConfig,
type LexicalEditor,
} from 'lexical';
import {describe, expect, it} from 'vitest';
const CONFIG: EditorConfig = {namespace: 'blockquote-line-test', theme: {composerBlockquoteLine: 'quote-line'}};
function createTestEditor(): LexicalEditor {
return createEditor({
namespace: 'blockquote-line-test',
nodes: [ComposerBlockquoteLineNode],
onError: (error) => {
throw error;
},
});
}
describe('ComposerBlockquoteLineNode', () => {
it('creates a span carrying the theme class', () => {
const editor = createTestEditor();
let dom: HTMLElement | null = null;
editor.update(
() => {
dom = $createComposerBlockquoteLineNode().createDOM(CONFIG);
},
{discrete: true},
);
expect(dom).not.toBeNull();
expect(dom!.tagName).toBe('SPAN');
expect(dom!.className).toBe('quote-line');
});
it('is an inline node that cannot be left empty', () => {
const editor = createTestEditor();
let shape: {inline: boolean; canBeEmpty: boolean} | null = null;
editor.update(
() => {
const wrapper = $createComposerBlockquoteLineNode();
shape = {inline: wrapper.isInline(), canBeEmpty: wrapper.canBeEmpty()};
},
{discrete: true},
);
expect(shape).toEqual({inline: true, canBeEmpty: false});
});
it('inserts an attached empty sibling wrapper on insertNewAfter', () => {
const editor = createTestEditor();
let result: {isWrapper: boolean; attached: boolean; children: number; isNextSibling: boolean} | null = null;
editor.update(
() => {
const paragraph = $createParagraphNode();
const wrapper = $createComposerBlockquoteLineNode();
wrapper.append($createTextNode('a'));
paragraph.append(wrapper);
$getRoot().clear().append(paragraph);
const next = wrapper.insertNewAfter($createRangeSelection(), false);
result = {
isWrapper: $isComposerBlockquoteLineNode(next),
attached: next.isAttached(),
children: next.getChildrenSize(),
isNextSibling: next.is(wrapper.getNextSibling()),
};
},
{discrete: true},
);
expect(result).toEqual({isWrapper: true, attached: true, children: 0, isNextSibling: true});
});
});
describe('$getComposerLineNodes', () => {
it('expands wrappers in document order and leaves other children alone', () => {
const editor = createTestEditor();
let texts: Array<string> = [];
let keysMatch = false;
editor.update(
() => {
const paragraph = $createParagraphNode();
const lead = $createTextNode('a');
const wrapper = $createComposerBlockquoteLineNode();
const marker = $createTextNode('> ');
const body = $createTextNode('b');
wrapper.append(marker, body);
const tail = $createTextNode('c');
paragraph.append(lead, wrapper, tail);
$getRoot().clear().append(paragraph);
const nodes = $getComposerLineNodes(paragraph);
texts = nodes.map((node) => node.getTextContent());
keysMatch = nodes.every((node, index) => node.is([lead, marker, body, tail][index]));
},
{discrete: true},
);
expect(texts).toEqual(['a', '> ', 'b', 'c']);
expect(keysMatch).toBe(true);
});
it('returns the children unchanged when there is no wrapper', () => {
const editor = createTestEditor();
let texts: Array<string> = [];
editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('a'), $createTextNode('b'));
$getRoot().clear().append(paragraph);
texts = $getComposerLineNodes(paragraph).map((node) => node.getTextContent());
},
{discrete: true},
);
expect(texts).toEqual(['a', 'b']);
});
});
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
$applyNodeReplacement,
type EditorConfig,
ElementNode,
type LexicalNode,
type RangeSelection,
type SerializedElementNode,
} from 'lexical';
export class ComposerBlockquoteLineNode extends ElementNode {
static override getType(): string {
return 'composer-blockquote-line';
}
static override clone(node: ComposerBlockquoteLineNode): ComposerBlockquoteLineNode {
return new ComposerBlockquoteLineNode(node.__key);
}
static override importJSON(serializedNode: SerializedElementNode): ComposerBlockquoteLineNode {
return $createComposerBlockquoteLineNode().updateFromJSON(serializedNode);
}
override createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const className = config.theme.composerBlockquoteLine;
if (typeof className === 'string') {
span.className = className;
}
return span;
}
override updateDOM(): boolean {
return false;
}
override isInline(): true {
return true;
}
override canBeEmpty(): false {
return false;
}
override insertNewAfter(_selection: RangeSelection, restoreSelection = true): ComposerBlockquoteLineNode {
const next = $createComposerBlockquoteLineNode();
this.insertAfter(next, restoreSelection);
return next;
}
}
export function $createComposerBlockquoteLineNode(): ComposerBlockquoteLineNode {
return $applyNodeReplacement(new ComposerBlockquoteLineNode());
}
export function $isComposerBlockquoteLineNode(
node: LexicalNode | null | undefined,
): node is ComposerBlockquoteLineNode {
return node instanceof ComposerBlockquoteLineNode;
}
export function $getComposerLineNodes(element: ElementNode): Array<LexicalNode> {
const nodes: Array<LexicalNode> = [];
for (const child of element.getChildren()) {
if ($isComposerBlockquoteLineNode(child)) {
nodes.push(...$getComposerLineNodes(child));
} else {
nodes.push(child);
}
}
return nodes;
}
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import type {EditorConfig, LexicalNode, SerializedTextNode} from 'lexical';
export class ComposerBlockquoteMarkerNode extends SyntaxMarkerNode {
static override getType(): string {
return 'composer-blockquote-marker';
}
static override clone(node: ComposerBlockquoteMarkerNode): ComposerBlockquoteMarkerNode {
return new ComposerBlockquoteMarkerNode(node.__text, node.__key);
}
static override importJSON(serializedNode: SerializedTextNode): ComposerBlockquoteMarkerNode {
return $createComposerBlockquoteMarkerNode(serializedNode.text).updateFromJSON(serializedNode);
}
override createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
const className = config.theme.composerBlockquoteMarker;
if (typeof className === 'string') {
dom.classList.add(className);
}
return dom;
}
override canInsertTextBefore(): false {
return false;
}
override canInsertTextAfter(): false {
return false;
}
}
export function $createComposerBlockquoteMarkerNode(text: string): ComposerBlockquoteMarkerNode {
return new ComposerBlockquoteMarkerNode(text);
}
export function $isComposerBlockquoteMarkerNode(
node: LexicalNode | null | undefined,
): node is ComposerBlockquoteMarkerNode {
return node instanceof ComposerBlockquoteMarkerNode;
}
@@ -12,6 +12,7 @@ import Channels from '@app/features/channel/state/Channels';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {MessageForwardFailedModal} from '@app/features/messaging/components/alerts/MessageForwardFailedModal';
@@ -225,7 +226,7 @@ export const ForwardModal = observer(
return selectedChannel == null ? null : selectedChannel;
}, [mostRecentlySelectedChannelId]);
const handleCommentChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
setActualOptionalMessage(wire);
setActualOptionalMessage(dropTrailingEmptyBlockquoteLines(wire));
}, []);
const isCommentOverLimit = actualOptionalMessage.length > user.maxMessageLength;
const isSendBlockedBySlowmode = slowmodeActiveSelectedChannelOptions.length > 0;
@@ -9,6 +9,7 @@ import Emoji from '@app/features/emoji/state/Emoji';
import {checkEmojiAvailabilityWithGuildFallback} from '@app/features/expressions/utils/ExpressionPermissionUtils';
import ChannelMemberCount from '@app/features/guild/state/ChannelMemberCount';
import Guilds from '@app/features/guild/state/Guilds';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
import {
type LexicalMessageCommandResolution,
@@ -446,7 +447,7 @@ export const useTextareaSubmit = ({
let actualContent = displayToActual(value).trim();
if (composerHandle !== null) {
lexicalCommand = LexicalMessageCommandResolver.resolve(composerHandle);
actualContent = composerHandle.getWireValue().trim();
actualContent = dropTrailingEmptyBlockquoteLines(composerHandle.getWireValue()).trim();
}
const resolvedContent = resolveTypedEmojiContent(actualContent);
let parsedCommand: CommandUtils.ParsedCommand | null = null;
@@ -12,6 +12,7 @@ import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import Guilds from '@app/features/guild/state/Guilds';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import type {LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import * as GuildMemberCommands from '@app/features/member/commands/GuildMemberCommands';
import GuildMembers from '@app/features/member/state/GuildMembers';
@@ -448,7 +449,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
const handleBioChange = useCallback((display: string, segments: Array<MentionSegment>, wire: string) => {
setBioValue(display);
setBioSegments(segments);
setBioActualValue(wire);
setBioActualValue(dropTrailingEmptyBlockquoteLines(wire));
}, []);
const onSubmit = useCallback(
async (data: FormInputs) => {