mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(app): edit blockquote lines in the composer (#2676)
This commit is contained in:
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) {
|
||||
|
||||
+2
-1
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user