mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +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 {checkEmojiAvailability} from '@app/features/expressions/utils/ExpressionPermissionUtils';
|
||||||
import Guilds from '@app/features/guild/state/Guilds';
|
import Guilds from '@app/features/guild/state/Guilds';
|
||||||
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
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 GuildMembers from '@app/features/member/state/GuildMembers';
|
||||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||||
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
|
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
|
||||||
@@ -193,7 +194,7 @@ export const UserMessage = observer(() => {
|
|||||||
if (message.messageSnapshots) {
|
if (message.messageSnapshots) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = (actualContent ?? '').trim();
|
const content = dropTrailingEmptyBlockquoteLines(actualContent ?? '').trim();
|
||||||
if (!content) {
|
if (!content) {
|
||||||
if (canSubmitEmptyMessageEdit(message)) {
|
if (canSubmitEmptyMessageEdit(message)) {
|
||||||
if (message.content.length === 0) {
|
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 type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||||
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
||||||
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
|
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 {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
|
||||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||||
import {convertMarkdownToSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
|
import {convertMarkdownToSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
|
||||||
@@ -145,7 +146,7 @@ export const ChannelOverviewTopicEditor = observer(
|
|||||||
});
|
});
|
||||||
}, [actualTopic, form, isTopicInitialized]);
|
}, [actualTopic, form, isTopicInitialized]);
|
||||||
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
|
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
|
||||||
setActualTopic(wire);
|
setActualTopic(dropTrailingEmptyBlockquoteLines(wire));
|
||||||
}, []);
|
}, []);
|
||||||
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
|
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
|
||||||
const composer = composerRef.current;
|
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,
|
$selectComposerNodeBoundary,
|
||||||
$selectComposerRange,
|
$selectComposerRange,
|
||||||
} from '@app/features/lexical/composer/composerOffsets';
|
} 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 {$createComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
|
||||||
import {
|
import {
|
||||||
$createComposerStandardEmojiNode,
|
$createComposerStandardEmojiNode,
|
||||||
@@ -72,7 +73,7 @@ export function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiRes
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parent = node.getParent();
|
const parent = node.getParent();
|
||||||
if (parent == null || parent.getType() !== 'paragraph') {
|
if (parent == null || (parent.getType() !== 'paragraph' && !$isComposerBlockquoteLineNode(parent))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = node.getTextContent();
|
const text = node.getTextContent();
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 {$captureSelectionOffsets, $selectComposerRange} from '@app/features/lexical/composer/composerOffsets';
|
||||||
import {
|
import {
|
||||||
computeMarkdownHighlightSpans,
|
computeMarkdownHighlightSpans,
|
||||||
@@ -8,6 +15,11 @@ import {
|
|||||||
type MarkdownSpan,
|
type MarkdownSpan,
|
||||||
markSilentMessagePrefix,
|
markSilentMessagePrefix,
|
||||||
} from '@app/features/lexical/composer/markdownSpans';
|
} 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 {$isComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
|
||||||
import {$isComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
|
import {$isComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +36,6 @@ import {
|
|||||||
$createTextNode,
|
$createTextNode,
|
||||||
$getNodeByKey,
|
$getNodeByKey,
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$isLineBreakNode,
|
|
||||||
$isNodeSelection,
|
$isNodeSelection,
|
||||||
$isRangeSelection,
|
$isRangeSelection,
|
||||||
$setSelection,
|
$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',
|
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.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.subtext, style: 'font-size:0.85em;color:var(--text-muted,var(--text-secondary))'},
|
||||||
{bit: MarkdownHl.link, style: 'color:var(--text-link)'},
|
{bit: MarkdownHl.link, style: 'color:var(--text-link)'},
|
||||||
{bit: MarkdownHl.codeBlock, style: 'font-size:0.75em'},
|
{bit: MarkdownHl.codeBlock, style: 'font-size:0.75em'},
|
||||||
@@ -98,6 +108,7 @@ export function registerComposerMarkdownHighlight(
|
|||||||
|
|
||||||
type Desired =
|
type Desired =
|
||||||
| {role: 'marker'; text: string}
|
| {role: 'marker'; text: string}
|
||||||
|
| {role: 'quote'; text: string}
|
||||||
| {role: 'content'; text: string; format: MarkdownHlFormat}
|
| {role: 'content'; text: string; format: MarkdownHlFormat}
|
||||||
| {role: 'keep'; node: LexicalNode};
|
| {role: 'keep'; node: LexicalNode};
|
||||||
|
|
||||||
@@ -105,31 +116,26 @@ type BuildableDesired = Exclude<Desired, {role: 'keep'}>;
|
|||||||
|
|
||||||
export function $reconcileLineOf(node: TextNode, parserFlags?: number): void {
|
export function $reconcileLineOf(node: TextNode, parserFlags?: number): void {
|
||||||
const parent = node.getParent();
|
const parent = node.getParent();
|
||||||
if (parent == null || parent.getType() !== 'paragraph') {
|
const block = $isComposerBlockquoteLineNode(parent) ? parent.getParent() : parent;
|
||||||
|
if (block == null || block.getType() !== 'paragraph') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$reconcileParagraph(parent as ParagraphNode, parserFlags);
|
$reconcileParagraph(block as ParagraphNode, parserFlags);
|
||||||
}
|
}
|
||||||
|
|
||||||
function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, silentMessagePrefix = false): void {
|
function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, silentMessagePrefix = false): void {
|
||||||
const lines: Array<Array<LexicalNode>> = [];
|
const lines = $splitComposerLines(paragraph);
|
||||||
let line: Array<LexicalNode> = [];
|
const lineSources = lines.map((line) => line.nodes.map($nodeWireText).join(''));
|
||||||
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 source = lineSources.join('\n');
|
const source = lineSources.join('\n');
|
||||||
const markdownSpans = computeMarkdownHighlightSpans(source, parserFlags);
|
const markdownSpans = computeMarkdownHighlightSpans(source, parserFlags);
|
||||||
|
const quoteMarkerEnds = findBlockquoteMarkerEnds(source, markdownSpans);
|
||||||
|
if ($rewriteMultilineBlockquoteMarker(lines, lineSources, quoteMarkerEnds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const spans = silentMessagePrefix ? markSilentMessagePrefix(markdownSpans, source) : markdownSpans;
|
const spans = silentMessagePrefix ? markSilentMessagePrefix(markdownSpans, source) : markdownSpans;
|
||||||
let lineStart = 0;
|
let lineStart = 0;
|
||||||
for (let index = 0; index < lines.length; index += 1) {
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
const nodes = lines[index]!;
|
const nodes = lines[index]!.nodes;
|
||||||
const lineSource = lineSources[index]!;
|
const lineSource = lineSources[index]!;
|
||||||
const lineEnd = lineStart + lineSource.length;
|
const lineEnd = lineStart + lineSource.length;
|
||||||
if (nodes.length > 0) {
|
if (nodes.length > 0) {
|
||||||
@@ -140,10 +146,17 @@ function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, sil
|
|||||||
end: Math.min(span.end, lineEnd) - lineStart,
|
end: Math.min(span.end, lineEnd) - lineStart,
|
||||||
}))
|
}))
|
||||||
.filter((span) => span.end > span.start);
|
.filter((span) => span.end > span.start);
|
||||||
$reconcileLine(nodes, parserFlags, lineSource, lineSpans);
|
$reconcileLine(nodes, parserFlags, lineSource, lineSpans, quoteMarkerEnds[index]);
|
||||||
}
|
}
|
||||||
lineStart = lineEnd + 1;
|
lineStart = lineEnd + 1;
|
||||||
}
|
}
|
||||||
|
$syncComposerBlockquoteLines(
|
||||||
|
paragraph,
|
||||||
|
quoteMarkerEnds.map((end) => end > 0),
|
||||||
|
);
|
||||||
|
if (quoteMarkerEnds.some((end) => end > 0)) {
|
||||||
|
$snapCaretOutOfBlockquoteMarker();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function $isEscapedAtOffset(source: string, offset: number): boolean {
|
function $isEscapedAtOffset(source: string, offset: number): boolean {
|
||||||
@@ -161,6 +174,7 @@ function $reconcileLine(
|
|||||||
parserFlags?: number,
|
parserFlags?: number,
|
||||||
precomputedSource?: string,
|
precomputedSource?: string,
|
||||||
precomputedSpans?: Array<MarkdownSpan>,
|
precomputedSpans?: Array<MarkdownSpan>,
|
||||||
|
quoteMarkerEnd = 0,
|
||||||
): void {
|
): void {
|
||||||
const desired: Array<Desired> = [];
|
const desired: Array<Desired> = [];
|
||||||
const source = precomputedSource == null ? line.map($nodeWireText).join('') : precomputedSource;
|
const source = precomputedSource == null ? line.map($nodeWireText).join('') : precomputedSource;
|
||||||
@@ -210,7 +224,16 @@ function $reconcileLine(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const text = source.slice(start, end);
|
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 {
|
} else {
|
||||||
desired.push({role: 'keep', node});
|
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) {
|
if (!(node instanceof TextNode) || node.getTextContent() !== want.text) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (want.role === 'marker') {
|
if (want.role === 'quote') {
|
||||||
if (!$isSyntaxMarkerNode(node)) {
|
if (!$isComposerBlockquoteMarkerNode(node)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (want.role === 'marker') {
|
||||||
|
if (!$isSyntaxMarkerNode(node) || $isComposerBlockquoteMarkerNode(node)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if ($isSyntaxMarkerNode(node) || !$contentNodeMatchesFormat(node, want.format)) {
|
} 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 {
|
function $buildDescriptorNode(desired: BuildableDesired): TextNode {
|
||||||
|
if (desired.role === 'quote') {
|
||||||
|
return $createComposerBlockquoteMarkerNode(desired.text);
|
||||||
|
}
|
||||||
if (desired.role === 'marker') {
|
if (desired.role === 'marker') {
|
||||||
return $createSyntaxMarkerNode(desired.text);
|
return $createSyntaxMarkerNode(desired.text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {$getComposerLineNodes} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
|
||||||
import {
|
import {
|
||||||
$createComposerCommandNode,
|
$createComposerCommandNode,
|
||||||
$isComposerCommandNode,
|
$isComposerCommandNode,
|
||||||
@@ -227,7 +228,7 @@ export function $projectComposer(): ComposerProjection {
|
|||||||
wire += block.getTextContent();
|
wire += block.getTextContent();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const child of block.getChildren()) {
|
for (const child of $getComposerLineNodes(block)) {
|
||||||
if ($isLineBreakNode(child)) {
|
if ($isLineBreakNode(child)) {
|
||||||
display += '\n';
|
display += '\n';
|
||||||
wire += '\n';
|
wire += '\n';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
$createComposerInsertNode,
|
$createComposerInsertNode,
|
||||||
$selectComposerRange,
|
$selectComposerRange,
|
||||||
} from '@app/features/lexical/composer/composerOffsets';
|
} 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 {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
|
||||||
import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
|
import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
|
||||||
import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
|
import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
|
||||||
@@ -93,7 +94,7 @@ function $convertSpecialMention(root: RootNode, plainText: boolean): void {
|
|||||||
source += '\n';
|
source += '\n';
|
||||||
displayLength += 1;
|
displayLength += 1;
|
||||||
}
|
}
|
||||||
for (const child of $isElementNode(block) ? block.getChildren() : [block]) {
|
for (const child of $isElementNode(block) ? $getComposerLineNodes(block) : [block]) {
|
||||||
if (
|
if (
|
||||||
$isTextNode(child) &&
|
$isTextNode(child) &&
|
||||||
child.isSimpleText() &&
|
child.isSimpleText() &&
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import type {AutocompleteOption, AutocompleteType} from '@app/features/channel/components/AutocompleteTypes';
|
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 {registerComposerClipboardCommands} from '@app/features/lexical/composer/ComposerClipboard';
|
||||||
import {registerComposerCodeIndent} from '@app/features/lexical/composer/ComposerCodeIndent';
|
import {registerComposerCodeIndent} from '@app/features/lexical/composer/ComposerCodeIndent';
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +41,8 @@ import {
|
|||||||
} from '@app/features/lexical/composer/composerOffsets';
|
} from '@app/features/lexical/composer/composerOffsets';
|
||||||
import styles from '@app/features/lexical/composer/LexicalMessageComposer.module.css';
|
import styles from '@app/features/lexical/composer/LexicalMessageComposer.module.css';
|
||||||
import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans';
|
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 {ComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
|
||||||
import {ComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
|
import {ComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
|
||||||
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
|
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
|
||||||
@@ -105,6 +108,8 @@ import {useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, u
|
|||||||
const THEME: InitialConfigType['theme'] = {
|
const THEME: InitialConfigType['theme'] = {
|
||||||
paragraph: styles.paragraph,
|
paragraph: styles.paragraph,
|
||||||
syntaxMarker: styles.marker,
|
syntaxMarker: styles.marker,
|
||||||
|
composerBlockquoteLine: styles.blockquoteLine,
|
||||||
|
composerBlockquoteMarker: styles.blockquoteMarker,
|
||||||
composerMention: clsx(styles.mentionHost, markupStyles.inlineFormat),
|
composerMention: clsx(styles.mentionHost, markupStyles.inlineFormat),
|
||||||
composerCustomEmoji: styles.emojiHost,
|
composerCustomEmoji: styles.emojiHost,
|
||||||
composerCommand: styles.command,
|
composerCommand: styles.command,
|
||||||
@@ -209,6 +214,8 @@ export const LexicalComposerInput = observer((props: LexicalComposerInputProps)
|
|||||||
SlashSeparatorNode,
|
SlashSeparatorNode,
|
||||||
SlashOptionalHintNode,
|
SlashOptionalHintNode,
|
||||||
SyntaxMarkerNode,
|
SyntaxMarkerNode,
|
||||||
|
ComposerBlockquoteLineNode,
|
||||||
|
ComposerBlockquoteMarkerNode,
|
||||||
],
|
],
|
||||||
theme: THEME,
|
theme: THEME,
|
||||||
};
|
};
|
||||||
@@ -517,6 +524,7 @@ const ComposerInner = ({
|
|||||||
cleanups.push(registerSlashSlotFocus(editor, () => onSlashCommandStateChangeRef.current));
|
cleanups.push(registerSlashSlotFocus(editor, () => onSlashCommandStateChangeRef.current));
|
||||||
if (markdown) {
|
if (markdown) {
|
||||||
cleanups.push(registerComposerMarkdownHighlight(editor, markdownParserFlags, silentMessagePrefix));
|
cleanups.push(registerComposerMarkdownHighlight(editor, markdownParserFlags, silentMessagePrefix));
|
||||||
|
cleanups.push(registerComposerBlockquote(editor));
|
||||||
}
|
}
|
||||||
cleanups.push(
|
cleanups.push(
|
||||||
registerComposerEmojiShortcode(editor, (shortcodeName) => {
|
registerComposerEmojiShortcode(editor, (shortcodeName) => {
|
||||||
|
|||||||
@@ -54,6 +54,37 @@
|
|||||||
opacity: 0.45;
|
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 {
|
.bold {
|
||||||
font-weight: 700;
|
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,
|
$wrapComposerSelection,
|
||||||
} from '@app/features/lexical/composer/composerOffsets';
|
} from '@app/features/lexical/composer/composerOffsets';
|
||||||
import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans';
|
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 {
|
import {
|
||||||
$isComposerCustomEmojiNode,
|
$isComposerCustomEmojiNode,
|
||||||
ComposerCustomEmojiNode,
|
ComposerCustomEmojiNode,
|
||||||
@@ -261,7 +263,13 @@ const PARSER_ROWS: Array<[text: string, start: number, end: number, content: str
|
|||||||
function createComposer(): LexicalEditor {
|
function createComposer(): LexicalEditor {
|
||||||
const editor = createEditor({
|
const editor = createEditor({
|
||||||
namespace: 'code-block-wrap-test',
|
namespace: 'code-block-wrap-test',
|
||||||
nodes: [ComposerCustomEmojiNode, ComposerMentionNode, SyntaxMarkerNode],
|
nodes: [
|
||||||
|
ComposerBlockquoteLineNode,
|
||||||
|
ComposerBlockquoteMarkerNode,
|
||||||
|
ComposerCustomEmojiNode,
|
||||||
|
ComposerMentionNode,
|
||||||
|
SyntaxMarkerNode,
|
||||||
|
],
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
throw error;
|
throw error;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 {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 {
|
import {
|
||||||
$createComposerCustomEmojiNode,
|
$createComposerCustomEmojiNode,
|
||||||
$isComposerCustomEmojiNode,
|
$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 {
|
export function $getComposerSelectionRange(): {start: number; end: number} | null {
|
||||||
const offsets = $captureSelectionOffsets();
|
const offsets = $captureSelectionOffsets();
|
||||||
if (offsets == null) {
|
if (offsets == null) {
|
||||||
@@ -220,10 +258,6 @@ export function $getComposerDisplayText(): string {
|
|||||||
return $buildDisplayLayout().text;
|
return $buildDisplayLayout().text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function $getComposerScanText(): string {
|
|
||||||
return scanTextFromLayout($buildDisplayLayout());
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanTextFromLayout({text, leaves}: DisplayLayout): string {
|
function scanTextFromLayout({text, leaves}: DisplayLayout): string {
|
||||||
let scanText = '';
|
let scanText = '';
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
@@ -241,6 +275,10 @@ function scanTextFromLayout({text, leaves}: DisplayLayout): string {
|
|||||||
return scanText + text.slice(offset);
|
return scanText + text.slice(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function $getComposerScanText(): string {
|
||||||
|
return scanTextFromLayout($buildDisplayLayout());
|
||||||
|
}
|
||||||
|
|
||||||
function $pointAtDisplayOffset(layout: DisplayLayout, offset: number): DisplayPoint {
|
function $pointAtDisplayOffset(layout: DisplayLayout, offset: number): DisplayPoint {
|
||||||
const clampedOffset = Math.min(Math.max(0, offset), layout.text.length);
|
const clampedOffset = Math.min(Math.max(0, offset), layout.text.length);
|
||||||
for (const leaf of layout.leaves) {
|
for (const leaf of layout.leaves) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const MarkdownHl = {
|
|||||||
link: 1 << 9,
|
link: 1 << 9,
|
||||||
codeBlock: 1 << 10,
|
codeBlock: 1 << 10,
|
||||||
silent: 1 << 11,
|
silent: 1 << 11,
|
||||||
|
blockquoteMarker: 1 << 12,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type MarkdownHlFormat = number;
|
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') {
|
while (ctx.source[trimmedStart] === ' ' || ctx.source[trimmedStart] === '\t' || ctx.source[trimmedStart] === '\r') {
|
||||||
trimmedStart += 1;
|
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> = [];
|
const sourceOffsets: Array<number> = [];
|
||||||
let virtualSource = '';
|
let virtualSource = '';
|
||||||
let blockEnd = start;
|
let blockEnd = start;
|
||||||
if (ctx.source.startsWith('>>> ', trimmedStart)) {
|
if (ctx.source.startsWith('>>> ', trimmedStart)) {
|
||||||
const contentStart = trimmedStart + 4;
|
const contentStart = trimmedStart + 4;
|
||||||
markerRanges.push({start, end: contentStart});
|
markerRanges.push({start, end: contentStart, quote: true});
|
||||||
virtualSource = ctx.source.slice(contentStart);
|
virtualSource = ctx.source.slice(contentStart);
|
||||||
for (let position = contentStart; position < ctx.source.length; position += 1) {
|
for (let position = contentStart; position < ctx.source.length; position += 1) {
|
||||||
sourceOffsets.push(position);
|
sourceOffsets.push(position);
|
||||||
}
|
}
|
||||||
blockEnd = ctx.source.length;
|
blockEnd = ctx.source.length;
|
||||||
} else {
|
} else {
|
||||||
const lines: Array<{start: number; end: number; contentStart: number}> = [];
|
const lines: Array<{start: number; end: number; prefixEnd: number; contentStart: number}> = [];
|
||||||
let lineStart = start;
|
let lineStart = start;
|
||||||
while (lineStart <= ctx.source.length) {
|
while (lineStart <= ctx.source.length) {
|
||||||
const newline = ctx.source.indexOf('\n', lineStart);
|
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 rest = ctx.source.slice(quoteStart + 2, lineEnd);
|
||||||
const contentStart = /^[ \t\r]*$/.test(rest) ? lineEnd : quoteStart + 2;
|
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) {
|
if (newline < 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -832,10 +834,13 @@ function alignBlockquote(children: Array<Node>, format: MarkdownHlFormat, ctx: A
|
|||||||
}
|
}
|
||||||
for (let index = 0; index < lines.length; index += 1) {
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
const line = lines[index]!;
|
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;
|
let repeatedPrefix = line.contentStart;
|
||||||
while (ctx.source.startsWith('> ', repeatedPrefix) && repeatedPrefix < line.end) {
|
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;
|
repeatedPrefix += 2;
|
||||||
}
|
}
|
||||||
for (let position = line.contentStart; position < line.end; position += 1) {
|
for (let position = line.contentStart; position < line.end; position += 1) {
|
||||||
@@ -890,12 +895,18 @@ function alignBlockquote(children: Array<Node>, format: MarkdownHlFormat, ctx: A
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
roles[sourceOffset - start] = span.role;
|
roles[sourceOffset - start] = span.role;
|
||||||
formats[sourceOffset - start] = span.format;
|
formats[sourceOffset - start] = span.format & ~MarkdownHl.blockquoteMarker;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const range of markerRanges) {
|
for (const range of markerRanges) {
|
||||||
for (let position = range.start; position < range.end; position += 1) {
|
for (let position = range.start; position < range.end; position += 1) {
|
||||||
roles[position - start] = 'marker';
|
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;
|
formats[position - start] = format;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -933,7 +944,7 @@ function alignAlert(format: MarkdownHlFormat, ctx: AlignContext): void {
|
|||||||
}
|
}
|
||||||
const newline = ctx.source.indexOf('\n', ctx.pos);
|
const newline = ctx.source.indexOf('\n', ctx.pos);
|
||||||
const lineEnd = newline < 0 ? ctx.source.length : newline;
|
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) {
|
if (firstLine) {
|
||||||
const close = ctx.source.indexOf(']', ctx.pos);
|
const close = ctx.source.indexOf(']', ctx.pos);
|
||||||
if (!ctx.source.startsWith('[!', ctx.pos) || close < 0 || close >= lineEnd) {
|
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 type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||||
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
||||||
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
|
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 {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
|
||||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||||
import {MessageForwardFailedModal} from '@app/features/messaging/components/alerts/MessageForwardFailedModal';
|
import {MessageForwardFailedModal} from '@app/features/messaging/components/alerts/MessageForwardFailedModal';
|
||||||
@@ -225,7 +226,7 @@ export const ForwardModal = observer(
|
|||||||
return selectedChannel == null ? null : selectedChannel;
|
return selectedChannel == null ? null : selectedChannel;
|
||||||
}, [mostRecentlySelectedChannelId]);
|
}, [mostRecentlySelectedChannelId]);
|
||||||
const handleCommentChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
|
const handleCommentChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
|
||||||
setActualOptionalMessage(wire);
|
setActualOptionalMessage(dropTrailingEmptyBlockquoteLines(wire));
|
||||||
}, []);
|
}, []);
|
||||||
const isCommentOverLimit = actualOptionalMessage.length > user.maxMessageLength;
|
const isCommentOverLimit = actualOptionalMessage.length > user.maxMessageLength;
|
||||||
const isSendBlockedBySlowmode = slowmodeActiveSelectedChannelOptions.length > 0;
|
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 {checkEmojiAvailabilityWithGuildFallback} from '@app/features/expressions/utils/ExpressionPermissionUtils';
|
||||||
import ChannelMemberCount from '@app/features/guild/state/ChannelMemberCount';
|
import ChannelMemberCount from '@app/features/guild/state/ChannelMemberCount';
|
||||||
import Guilds from '@app/features/guild/state/Guilds';
|
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 {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
|
||||||
import {
|
import {
|
||||||
type LexicalMessageCommandResolution,
|
type LexicalMessageCommandResolution,
|
||||||
@@ -446,7 +447,7 @@ export const useTextareaSubmit = ({
|
|||||||
let actualContent = displayToActual(value).trim();
|
let actualContent = displayToActual(value).trim();
|
||||||
if (composerHandle !== null) {
|
if (composerHandle !== null) {
|
||||||
lexicalCommand = LexicalMessageCommandResolver.resolve(composerHandle);
|
lexicalCommand = LexicalMessageCommandResolver.resolve(composerHandle);
|
||||||
actualContent = composerHandle.getWireValue().trim();
|
actualContent = dropTrailingEmptyBlockquoteLines(composerHandle.getWireValue()).trim();
|
||||||
}
|
}
|
||||||
const resolvedContent = resolveTypedEmojiContent(actualContent);
|
const resolvedContent = resolveTypedEmojiContent(actualContent);
|
||||||
let parsedCommand: CommandUtils.ParsedCommand | null = null;
|
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 type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||||
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
||||||
import Guilds from '@app/features/guild/state/Guilds';
|
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 type {LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
|
||||||
import * as GuildMemberCommands from '@app/features/member/commands/GuildMemberCommands';
|
import * as GuildMemberCommands from '@app/features/member/commands/GuildMemberCommands';
|
||||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
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) => {
|
const handleBioChange = useCallback((display: string, segments: Array<MentionSegment>, wire: string) => {
|
||||||
setBioValue(display);
|
setBioValue(display);
|
||||||
setBioSegments(segments);
|
setBioSegments(segments);
|
||||||
setBioActualValue(wire);
|
setBioActualValue(dropTrailingEmptyBlockquoteLines(wire));
|
||||||
}, []);
|
}, []);
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
async (data: FormInputs) => {
|
async (data: FormInputs) => {
|
||||||
|
|||||||
Reference in new issue
Block a user