fix(app): insert a newline on Enter inside code blocks (#2674)

This commit is contained in:
Hampus
2026-09-11 13:20:26 +02:00
committed by GitHub
parent 2830221949
commit a5af857564
9 changed files with 1052 additions and 50 deletions
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {registerComposerCodeIndent} from '@app/features/lexical/composer/ComposerCodeIndent';
import {
$getComposerDisplayText,
$replaceComposerRange,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {createEditor, KEY_TAB_COMMAND} from 'lexical';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/lexical/composer/nodes/ComposerMentionPill', () => ({ComposerMentionPill: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerCustomEmoji', () => ({ComposerCustomEmoji: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerStandardEmoji', () => ({ComposerStandardEmoji: () => null}));
describe('registerComposerCodeIndent', () => {
it('indents a block opened after a mention whose name holds a backtick', () => {
const editor = createEditor({
namespace: 'composer-code-indent-test',
nodes: [ComposerMentionNode],
onError: (error) => {
throw error;
},
});
registerComposerCodeIndent(editor, {current: false});
editor.update(
() => {
$replaceComposerRange(
0,
0,
{kind: 'mention', mentionType: 'user', id: '1', display: '@`bob', wire: '<@1>'},
{trailing: false},
);
$replaceComposerRange(5, 5, {kind: 'text', text: ' hi ```js\ncode'}, {leading: false, trailing: false});
$selectComposerRange(19, 19);
},
{discrete: true},
);
const event = Object.assign(new Event('keydown', {cancelable: true}), {shiftKey: false}) as KeyboardEvent;
expect(editor.dispatchCommand(KEY_TAB_COMMAND, event)).toBe(true);
expect(editor.read(() => $getComposerDisplayText())).toBe('@`bob hi ```js\ncode\t');
});
});
@@ -4,7 +4,7 @@ import type {ComposerTypeaheadActiveState} from '@app/features/lexical/composer/
import {analyzeCodeIndent} from '@app/features/lexical/composer/codeBlockIndent';
import {
$captureSelectionOffsets,
$getComposerDisplayText,
$getComposerScanText,
$replaceComposerRange,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
@@ -27,7 +27,7 @@ export function registerComposerCodeIndent(
const backward = offsets.anchor > offsets.focus;
const start = Math.min(offsets.anchor, offsets.focus);
const end = Math.max(offsets.anchor, offsets.focus);
const plan = analyzeCodeIndent($getComposerDisplayText(), start, end, event != null && event.shiftKey === true);
const plan = analyzeCodeIndent($getComposerScanText(), start, end, event != null && event.shiftKey === true);
if (plan == null) {
return false;
}
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {registerComposerEnter} from '@app/features/lexical/composer/ComposerEnter';
import {$replaceComposerRange, $selectComposerRange} from '@app/features/lexical/composer/composerOffsets';
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {$createSlashSlotNode, SlashSlotNode} from '@app/features/lexical/composer/nodes/SlashSlotNode';
import {SlashSlotPlaceholderNode} from '@app/features/lexical/composer/nodes/SlashSlotPlaceholderNode';
import {$getRoot, $setSelection, createEditor, type ElementNode, KEY_ENTER_COMMAND, type LexicalEditor} from 'lexical';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/lexical/composer/nodes/ComposerMentionPill', () => ({ComposerMentionPill: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerCustomEmoji', () => ({ComposerCustomEmoji: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerStandardEmoji', () => ({ComposerStandardEmoji: () => null}));
interface EnterOptions {
modifiers?: Partial<Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>>;
submitOnEnter?: boolean;
typeaheadActive?: boolean;
}
const NEWLINE = {handled: false, prevented: false, sent: 0};
const SENT = {handled: true, prevented: true, sent: 1};
function createComposer(seed: () => void): LexicalEditor {
const editor = createEditor({
namespace: 'composer-enter-test',
nodes: [ComposerMentionNode, ComposerPlainSegmentNode, SlashSlotNode, SlashSlotPlaceholderNode],
onError: (error) => {
throw error;
},
});
editor.update(seed, {discrete: true});
return editor;
}
function typed(text: string, anchor: number, focus = anchor): LexicalEditor {
return createComposer(() => {
$replaceComposerRange(0, 0, {kind: 'text', text}, {leading: false, trailing: false});
$selectComposerRange(anchor, focus);
});
}
function typedWithRequiredSlot(text: string, offset: number): LexicalEditor {
return createComposer(() => {
$replaceComposerRange(0, 0, {kind: 'text', text}, {leading: false, trailing: false});
const slot = $createSlashSlotNode('value', 'string', true);
$getRoot().getFirstChildOrThrow<ElementNode>().append(slot);
slot.ensurePlaceholder();
$selectComposerRange(offset, offset);
});
}
function slotValidity(editor: LexicalEditor): string {
return editor.read(() =>
$getRoot().getFirstChildOrThrow<ElementNode>().getLastChildOrThrow<SlashSlotNode>().getValidity(),
);
}
function pressEnter(
editor: LexicalEditor,
{modifiers = {}, submitOnEnter = true, typeaheadActive = false}: EnterOptions = {},
): typeof SENT {
const onEnter = vi.fn();
registerComposerEnter(editor, {
typeaheadActiveState: {current: typeaheadActive},
getSubmitOnEnter: () => submitOnEnter,
getOnEnter: () => onEnter,
});
const event = Object.assign(
new Event('keydown', {cancelable: true}),
{altKey: false, ctrlKey: false, metaKey: false, shiftKey: false},
modifiers,
) as KeyboardEvent;
const handled = editor.dispatchCommand(KEY_ENTER_COMMAND, event);
return {handled, prevented: event.defaultPrevented, sent: onEnter.mock.calls.length};
}
describe('registerComposerEnter', () => {
it.each([
['after a lone opening fence', '```\ncode', 8],
['on the empty line after an opening fence', '```\n', 4],
['right before the closing fence', '```\ncode\n```', 9],
])('inserts a newline %s without preventing the key', (_label, text, offset) => {
expect(pressEnter(typed(text, offset))).toEqual(NEWLINE);
});
it('sends after the closing fence', () => {
expect(pressEnter(typed('```\ncode\n```', 12))).toEqual(SENT);
});
it('still sends plain text', () => {
expect(pressEnter(typed('hello', 5))).toEqual(SENT);
});
it('still sends on Ctrl+Enter inside a block', () => {
expect(pressEnter(typed('```\ncode', 8), {modifiers: {ctrlKey: true}})).toEqual(SENT);
});
it('still sends on Cmd+Enter inside a block', () => {
expect(pressEnter(typed('```\ncode', 8), {modifiers: {metaKey: true}})).toEqual(SENT);
});
it('inserts a newline on Alt+Enter inside a block', () => {
expect(pressEnter(typed('```\ncode', 8), {modifiers: {altKey: true}})).toEqual(NEWLINE);
});
it('still leaves Shift+Enter to the line break', () => {
expect(pressEnter(typed('hello', 5), {modifiers: {shiftKey: true}})).toEqual(NEWLINE);
});
it('still leaves Shift+Enter to the line break inside a block', () => {
expect(pressEnter(typed('```\ncode', 8), {modifiers: {shiftKey: true}})).toEqual(NEWLINE);
});
it('sends on a list item line that holds a fence', () => {
expect(pressEnter(typed('- ```', 5))).toEqual(SENT);
});
it('sends in prose that holds an unmatched fence', () => {
expect(pressEnter(typed('use ``` for code blocks', 23))).toEqual(SENT);
});
it('decides on the start of a backward selection inside a block', () => {
expect(pressEnter(typed('```\ncode', 7, 5))).toEqual(NEWLINE);
});
it('sends when the selection starts before the opening fence', () => {
expect(pressEnter(typed('```\ncode', 0, 8))).toEqual(SENT);
});
it('sends when nothing is selected', () => {
const editor = createComposer(() => {
$replaceComposerRange(0, 0, {kind: 'text', text: '```\ncode'}, {leading: false, trailing: false});
$setSelection(null);
});
expect(pressEnter(editor)).toEqual(SENT);
});
it('still lets an open typeahead take Enter inside a block', () => {
expect(pressEnter(typed('```\ncode', 8), {typeaheadActive: true})).toEqual(NEWLINE);
});
it('still inserts a newline on plain Enter when submit on Enter is off', () => {
expect(pressEnter(typed('```\ncode', 8), {submitOnEnter: false})).toEqual(NEWLINE);
});
it('still sends on Ctrl+Enter inside a block when submit on Enter is off', () => {
expect(pressEnter(typed('```\ncode', 8), {submitOnEnter: false, modifiers: {ctrlKey: true}})).toEqual(SENT);
});
it('inserts a newline inside a block without flagging an empty required slash slot', () => {
const editor = typedWithRequiredSlot('```\ncode', 8);
expect(pressEnter(editor)).toEqual(NEWLINE);
expect(slotValidity(editor)).toBe('neutral');
});
it('still flags an empty required slash slot instead of sending outside a block', () => {
const editor = typedWithRequiredSlot('hello', 5);
expect(pressEnter(editor)).toEqual({handled: true, prevented: true, sent: 0});
expect(slotValidity(editor)).toBe('invalid');
});
it.each([
['a mention', false],
['a plain text mention', true],
])('reads %s as filler so a backtick in its name cannot hide a fence', (_label, plainText) => {
const editor = createComposer(() => {
$replaceComposerRange(
0,
0,
{kind: 'mention', mentionType: 'user', id: '1', display: '@`bob', wire: '<@1>'},
{trailing: false},
plainText,
);
$replaceComposerRange(5, 5, {kind: 'text', text: ' hi ```js'}, {leading: false, trailing: false});
$selectComposerRange(14, 14);
});
expect(pressEnter(editor)).toEqual(NEWLINE);
});
});
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ComposerTypeaheadActiveState} from '@app/features/lexical/composer/ComposerTypeaheadModifierGuard';
import {isOffsetInsideCodeBlock} from '@app/features/lexical/composer/codeBlockIndent';
import {$getComposerScanText, $getComposerSelectionRange} from '@app/features/lexical/composer/composerOffsets';
import {$focusFirstInvalidSlashSlot} from '@app/features/lexical/composer/slashSlots';
import {COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, type LexicalEditor} from 'lexical';
interface ComposerEnterOptions {
typeaheadActiveState: ComposerTypeaheadActiveState;
getSubmitOnEnter: () => boolean;
getOnEnter: () => (() => void) | undefined;
}
function $isSelectionInCodeBlock(): boolean {
const range = $getComposerSelectionRange();
return range != null && isOffsetInsideCodeBlock($getComposerScanText(), range.start);
}
export function registerComposerEnter(
editor: LexicalEditor,
{typeaheadActiveState, getSubmitOnEnter, getOnEnter}: ComposerEnterOptions,
): () => void {
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent | null) => {
if (typeaheadActiveState.current || event == null) {
return false;
}
const onEnter = getOnEnter();
if (getSubmitOnEnter() && onEnter != null) {
if (!event.shiftKey) {
if (!event.ctrlKey && !event.metaKey && $isSelectionInCodeBlock()) {
return false;
}
event.preventDefault();
if ($focusFirstInvalidSlashSlot()) {
return true;
}
onEnter();
return true;
}
return false;
}
if ((event.metaKey || event.ctrlKey) && onEnter != null) {
event.preventDefault();
if ($focusFirstInvalidSlashSlot()) {
return true;
}
onEnter();
return true;
}
return false;
},
COMMAND_PRIORITY_HIGH,
);
}
@@ -7,6 +7,7 @@ import {
type ComposerEmojiResolver,
registerComposerEmojiShortcode,
} from '@app/features/lexical/composer/ComposerEmojiShortcode';
import {registerComposerEnter} from '@app/features/lexical/composer/ComposerEnter';
import type {ComposerHandle, ComposerSelectionRange} from '@app/features/lexical/composer/ComposerHandle';
import {resetComposerHistory} from '@app/features/lexical/composer/ComposerHistory';
import {registerComposerIMECommandGuard} from '@app/features/lexical/composer/ComposerIME';
@@ -55,7 +56,6 @@ import {
$applyOptionalChoice,
$applySlotChoice,
$applySlotPayload,
$focusFirstInvalidSlashSlot,
$getActiveOptionalContext,
$getActiveSlotAutocompleteContext,
$getActiveSlotChoiceContext,
@@ -97,7 +97,6 @@ import {
FOCUS_COMMAND,
HISTORY_MERGE_TAG,
KEY_ARROW_UP_COMMAND,
KEY_ENTER_COMMAND,
} from 'lexical';
import {observer} from 'mobx-react-lite';
import type React from 'react';
@@ -589,35 +588,11 @@ const ComposerInner = ({
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent | null) => {
if (typeaheadActiveState.current || event == null) {
return false;
}
if (submitOnEnterRef.current && cb.current.onEnter != null) {
if (!event.shiftKey) {
event.preventDefault();
if ($focusFirstInvalidSlashSlot()) {
return true;
}
cb.current.onEnter();
return true;
}
return false;
}
if ((event.metaKey || event.ctrlKey) && cb.current.onEnter != null) {
event.preventDefault();
if ($focusFirstInvalidSlashSlot()) {
return true;
}
cb.current.onEnter();
return true;
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
registerComposerEnter(editor, {
typeaheadActiveState,
getSubmitOnEnter: () => submitOnEnterRef.current,
getOnEnter: () => cb.current.onEnter,
}),
editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event: KeyboardEvent | null) => {
@@ -0,0 +1,369 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
analyzeCodeIndent,
type CodeIndentPlan,
isOffsetInsideCodeBlock,
scanCodeBlocks,
} from '@app/features/lexical/composer/codeBlockIndent';
import {
computeMarkdownHighlightSpans,
DEFAULT_COMPOSER_MARKDOWN_FLAGS,
MarkdownHl,
} from '@app/features/lexical/composer/markdownSpans';
import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasm';
import type {CodeBlockNode, Node} from '@app/features/messaging/utils/markdown/parser/Nodes';
import {describe, expect, it} from 'vitest';
type OffsetRow = [label: string, text: string, offset: number, expected: boolean];
const OFFSET_ROWS: Array<OffsetRow> = [
['a caret right after a lone opening fence is inside', '```', 3, true],
['a caret before the opening fence is outside', '```', 0, false],
['a caret within the opening backtick run is outside', '```', 2, false],
['two backticks do not open a block', '``', 2, false],
['a caret after the language of an unclosed fence is inside', '```js', 5, true],
['a caret between the fence and its language is inside', '```js', 3, true],
['the empty line after an opening fence is inside', '```\n', 4, true],
['the end of an unclosed block is inside', '```js\nconst a = 1', 17, true],
['a fence indented by four spaces opens a block', ' ```\ncode', 12, true],
['a tab-indented fence opens a block', '\t```\ncode', 9, true],
['a caret after the language on the opening line of a closed block is inside', '```js\ncode\n```', 5, true],
['a caret in the body of a closed block is inside', '```\ncode\n```', 6, true],
['the start of the closing fence line is inside', '```\ncode\n```', 9, true],
['a caret within the closing backtick run is outside', '```\ncode\n```', 10, false],
['a caret after the closing fence is outside', '```\ncode\n```', 12, false],
['trailing spaces after the closing fence stay outside', '```\ncode\n``` ', 14, false],
['the indent before an indented closing fence is inside', '```\ncode\n ```', 11, true],
['a caret after an indented closing fence is outside', '```\ncode\n ```', 14, false],
['content before a closing fence on the same line is inside', '```\ncode ```', 9, true],
['a caret after a closing fence that follows content is outside', '```\ncode ```', 12, false],
['a fence followed by text does not close the block', '```\ncode\n```x', 13, true],
['a longer backtick run closes the block', '```\ncode\n````', 13, false],
['a caret right after a closing fence with text after it is outside', '```\na\n``` x ```', 9, false],
['text after a closing fence can open a new block', '```\na\n``` x ```', 15, true],
['a caret inside a one-line block is inside', '```hello```', 5, true],
['a caret after a one-line block is outside', '```hello```', 11, false],
['the line after a one-line block is outside', '```hello```\nmore', 16, false],
['a one-line block inside prose holds the caret', 'use ```foo``` here', 9, true],
['prose after a one-line block is outside', 'use ```foo``` here', 18, false],
['a backtick left over after a one-line block is outside', '```a````', 8, false],
['a caret after a midline opening fence is inside', 'label```js\ncode', 15, true],
['a caret before a midline opening fence is outside', 'label```js\ncode', 5, false],
['a caret before the midline fence of a closed block is outside', 'label```js\ncode\n```', 5, false],
['an unclosed fence after text on the same line is inside', 'hello ```', 9, true],
['a sentence that mentions a fence stays outside', 'type ``` to start a block', 25, false],
['prose that holds an unmatched fence stays outside', 'use ``` for code blocks', 23, false],
['an unclosed fence whose info is not a language opens a block at the start of a line', '```js hello', 11, true],
['a lone fence followed by a space is inside', '``` ', 4, true],
['a midline fence after a preceding line holds its body', 'intro line\nlabel```rust\nfn main() {}\n```', 29, true],
[
'a caret after a midline block that follows a line is outside',
'intro line\nlabel```rust\nfn main() {}\n```',
40,
false,
],
['an escaped fence does not open a block', '\\```js\ncode', 11, false],
['an escaped backslash leaves the fence live', '\\\\```js\ncode', 12, true],
['a one-line block after an escaped backslash holds the caret', '\\\\```hello```', 7, true],
['a caret after a one-line block that follows an escaped backslash is outside', '\\\\```hello```', 13, false],
['a longer escaped fence stays text', '\\````hello````', 7, false],
['the end of a longer escaped fence stays outside', '\\````hello````', 14, false],
['an escaped midline fence stays text', 'label\\```rust\nfn main() {}\n```', 19, false],
['a fence inside inline code does not open a block', '`` ``` ``\ncode', 14, false],
['a fence inside inline code in prose does not open a block', 'use `` ``` `` to open a block', 29, false],
['a caret on a fence inside inline code is outside', 'use `` ``` `` to open a block', 8, false],
['a fence after closed inline code opens a block', '`code` ```js\nx', 14, true],
['a block opened after closed inline code holds its body', '`code` ```js\nxy\n```', 14, true],
['a caret after a block opened after closed inline code is outside', '`code` ```js\nxy\n```', 19, false],
['three backticks do not close a four-backtick fence', '````\ncode\n```\nmore', 18, true],
['a four-backtick block holds a three-backtick line', '````\ncode\n```\nmore\n````', 16, true],
['a caret after a four-backtick block is outside', '````\ncode\n```\nmore\n````', 23, false],
[
'an unclosed longer outer fence wins over a closed inner block because the user is still writing the outer block',
'````\ncode\n```\nmore\n```',
22,
true,
],
['the end of the first closing fence line is outside', '```\na\n```\ntext\n```\nb\n```', 9, false],
['text between two blocks is outside', '```\na\n```\ntext\n```\nb\n```', 12, false],
['the end of the text between two blocks is outside', '```\na\n```\ntext\n```\nb\n```', 14, false],
['the body of the second of two blocks is inside', '```\na\n```\ntext\n```\nb\n```', 20, true],
['a caret after the second of two blocks is outside', '```\na\n```\ntext\n```\nb\n```', 24, false],
['a block that opens after a closed block is inside', '```\na\n```\n```\n', 14, true],
['the caret after the opening fence of an empty block is inside', '```\n```', 3, true],
['the body of an empty block is inside', '```\n```', 4, true],
['the closing fence of an empty block reopens a block, as the parser reads it', '```\n```', 7, true],
['the empty middle line of an empty block is inside', '```\n\n```', 4, true],
['a closed block inside a block spoiler holds the caret', '||```\ncode\n```||', 10, true],
['a caret after a block spoiler around a closed block is outside', '||```\ncode\n```||', 16, false],
['a fence in an unclosed block spoiler opens a block', '||```\ncode', 10, true],
['an unclosed quoted fence ends with its quote', '> ```\ncode\n```', 10, false],
['an unquoted fence after a quoted fence opens another block', '> ```\ncode\n```', 14, true],
['a block closed on a quoted line holds the caret', '> ```\n> code\n> ```', 12, true],
['a caret after a block closed on a quoted line is outside', '> ```\n> code\n> ```', 18, false],
['a multiline quote holds a closed block', '>>> ```\ncode\n```', 12, true],
['a caret after a closed block in a multiline quote is outside', '>>> ```\ncode\n```', 16, false],
['an unclosed quoted fence does not hold the text after the quote', '> ```\ncode\nnormal text', 22, false],
['an unclosed quoted fence does not hold a later unquoted line', '> ```\n> code\nplain', 18, false],
['an unclosed spoilered fence does not hold the text after the spoiler', '||```\ncode|| after', 18, false],
['a fence on a list item line does not open a block', '- ```', 5, false],
['a fence on a numbered list item line does not open a block', '1. ```\ncode', 11, false],
['a closed block written on a list item line does not open a block', '- ```\ncode\n```', 8, false],
['a fence in prose on a list item line does not open a block', '- use ``` for code', 18, false],
['a fence on the line after a list item opens a block', '- a\n```\ncode\n```', 9, true],
['empty text is outside', '', 0, false],
['plain text is outside', 'hello', 5, false],
];
type BodyRow = [label: string, text: string, bodies: ReturnType<typeof scanCodeBlocks>];
const BODY_ROWS: Array<BodyRow> = [
['a closed block', '```\ncode\n```', [{start: 3, end: 9, rendered: true}]],
['an unclosed block runs to the end of the text', '```js\ncode', [{start: 3, end: 10, rendered: false}]],
[
'an empty block is not rendered and its closing fence opens another block',
'```\n\n```',
[
{start: 3, end: 5, rendered: false},
{start: 8, end: 8, rendered: false},
],
],
[
'an unclosed outer fence and the closed block inside it',
'````\ncode\n```\nmore\n```',
[
{start: 4, end: 22, rendered: false},
{start: 13, end: 19, rendered: true},
],
],
[
'a closed block inside an unclosed block spoiler is not rendered',
'||```\ncode\n```',
[{start: 5, end: 11, rendered: false}],
],
['a closed block inside a block spoiler', '||```\ncode\n```||', [{start: 5, end: 11, rendered: true}]],
['a fence written on a list item line', '- ```\ncode\n```', [{start: 14, end: 14, rendered: false}]],
['an unclosed quoted fence bounded by its quote', '> ```\ncode\nnormal text', [{start: 5, end: 5, rendered: false}]],
['an unclosed spoilered fence bounded by its spoiler', '||```\ncode|| after', [{start: 5, end: 10, rendered: false}]],
['prose that holds an unmatched fence', 'use ``` for code blocks', []],
];
type IndentRow = [
label: string,
text: string,
selectionStart: number,
selectionEnd: number,
unindent: boolean,
expected: CodeIndentPlan | null,
];
function tabAt(offset: number): CodeIndentPlan {
return {edits: [{start: offset, end: offset, text: '\t'}], selectionStart: offset + 1, selectionEnd: offset + 1};
}
const INDENT_ROWS: Array<IndentRow> = [
['still indents the caret in the body of a closed block', '```\ncode\n```', 4, 4, false, tabAt(4)],
['still ignores a caret on the closing fence line', '```\ncode\n```', 12, 12, false, null],
['still indents the caret at the end of an unclosed block', '```\ncode', 8, 8, false, tabAt(8)],
[
'still unindents a tab in the body',
'```\n\tcode\n```',
5,
5,
true,
{edits: [{start: 4, end: 5, text: ''}], selectionStart: 4, selectionEnd: 4},
],
[
'still indents every selected body line',
'```\na\nb\n```',
4,
7,
false,
{
edits: [
{start: 4, end: 4, text: '\t'},
{start: 6, end: 6, text: '\t'},
],
selectionStart: 5,
selectionEnd: 9,
},
],
['still ignores an indented closing fence line', '```\ncode\n ```', 14, 14, false, null],
['still treats content before a closing fence on its line as code', '```\ncode ```', 8, 8, false, tabAt(8)],
['no longer treats the line after a one-line block as code', '```hello```\nmore', 16, 16, false, null],
[
'no longer lets three backticks close a four-backtick fence',
'````\ncode\n```\nmore\n````',
18,
18,
false,
tabAt(18),
],
['recognises a midline opening fence', 'label```js\ncode\n```', 15, 15, false, tabAt(15)],
['recognises a fence indented by four spaces', ' ```\ncode', 12, 12, false, tabAt(12)],
['no longer indents the line after an unclosed quoted fence', '> ```\ncode\nnormal text', 22, 22, false, null],
['no longer indents the line after a fence on a list item line', '- ```\ncode', 10, 10, false, null],
];
const HIGHLIGHT_ROWS: Array<[text: string, offset: number, expected: boolean]> = [
['```\ncode\n```', 6, true],
['```hello```', 5, true],
['use ```foo``` here', 9, true],
['intro line\nlabel```rust\nfn main() {}\n```', 29, true],
['\\\\```hello```', 7, true],
['`code` ```js\nxy\n```', 14, true],
['````\ncode\n```\nmore\n````', 16, true],
['```\na\n```\ntext\n```\nb\n```', 20, true],
['```\na\n```\ntext\n```\nb\n```', 12, false],
['\\````hello````', 7, false],
['label\\```rust\nfn main() {}\n```', 19, false],
['use `` ``` `` to open a block', 8, false],
];
const CONTAINER_FIXTURES = [
'hi\n||```\ncode\n```||',
'||a||\n```\ncode\n```',
'||\n```\ncode\n```\n||',
'> > ```\n> code\n> ```',
'>>> a\n> ```\n> code\n> ```',
'> [!NOTE]\n> ```\n> code\n> ```',
'- a\n```\ncode\n```',
'```\n```\ncode\n```',
'```js title\ncode\n```',
];
const INVISIBLE_CONTENT_FIXTURES = [
'```\n\u200b\n```',
'```\n\u00a0\u3164\n```',
'```\u034f```',
'```\na\u034f\n```',
'```\n\ufe0f\n```\ncode\n```',
'||\u200b\n```\n||',
];
const CORPUS_PIECES = [
'```',
'```',
'`',
'\\',
' ',
' ',
'\t',
'\n',
'\n',
'\n',
'> ',
'>>> ',
'||',
'|',
'>',
'- ',
'* ',
'1. ',
'a',
'x',
];
const CORPUS_SIZE = 5000;
const CORPUS_MAX_PIECES = 24;
function createCorpus(): Array<string> {
let state = 0x2545f491;
const nextIndex = (bound: number): number => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return (state >>> 0) % bound;
};
return Array.from({length: CORPUS_SIZE}, () =>
Array.from({length: 1 + nextIndex(CORPUS_MAX_PIECES)}, () => CORPUS_PIECES[nextIndex(CORPUS_PIECES.length)]).join(
'',
),
);
}
function collectCodeBlocks(nodes: ReadonlyArray<Node>): Array<CodeBlockNode> {
return nodes.flatMap((node) => {
if (node.type === 'CodeBlock') {
return [node];
}
if (node.type === 'List') {
return node.items.flatMap((item) => collectCodeBlocks(item.children));
}
return 'children' in node ? collectCodeBlocks(node.children) : [];
});
}
function withoutQuoteMarkersOrWhitespace(value: string): string {
return value.replace(/[\s>]/g, '');
}
function parserCodeBlocks(text: string): Array<string> {
return collectCodeBlocks(parseMarkdownAstWithWasm(text, DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes).map((node) =>
withoutQuoteMarkersOrWhitespace(`${node.language ?? ''}${node.content}`),
);
}
function scannedCodeBlocks(text: string): Array<string> {
return scanCodeBlocks(text)
.filter((body) => body.rendered)
.map((body) => withoutQuoteMarkersOrWhitespace(text.slice(body.start, body.end)));
}
describe('isOffsetInsideCodeBlock', () => {
it.each(OFFSET_ROWS)('%s', (_label, text, offset, expected) => {
expect(isOffsetInsideCodeBlock(text, offset)).toBe(expected);
});
});
describe('scanCodeBlocks', () => {
it.each(BODY_ROWS)('reports %s', (_label, text, bodies) => {
expect(scanCodeBlocks(text)).toEqual(bodies);
});
});
describe('scanCodeBlocks against the markdown parser', () => {
const fixtures = new Set([
...OFFSET_ROWS.map(([, text]) => text),
...BODY_ROWS.map(([, text]) => text),
...INDENT_ROWS.map(([, text]) => text),
...CONTAINER_FIXTURES,
...INVISIBLE_CONTENT_FIXTURES,
]);
it.each([...fixtures])('closes the same blocks as the parser in %j', (text) => {
expect(scannedCodeBlocks(text)).toEqual(parserCodeBlocks(text));
});
it('closes the same blocks as the parser across a seeded generated corpus', () => {
const containers = new Set<string>();
for (const text of createCorpus()) {
for (const node of parseMarkdownAstWithWasm(text, DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes) {
if (collectCodeBlocks([node]).length > 0) {
containers.add(node.type);
}
}
expect({text, blocks: scannedCodeBlocks(text)}).toEqual({text, blocks: parserCodeBlocks(text)});
}
expect([...containers.keys()].sort()).toEqual(['Blockquote', 'CodeBlock', 'List', 'Spoiler']);
});
it.each(HIGHLIGHT_ROWS)('agrees with the composer highlight in %j at %i', (text, offset, expected) => {
expect(isOffsetInsideCodeBlock(text, offset)).toBe(expected);
expect(
computeMarkdownHighlightSpans(text).some(
(span) =>
span.role === 'content' &&
(span.format & MarkdownHl.codeBlock) !== 0 &&
span.start < offset &&
offset < span.end,
),
).toBe(expected);
});
});
describe('analyzeCodeIndent', () => {
it.each(INDENT_ROWS)('%s', (_label, text, selectionStart, selectionEnd, unindent, expected) => {
expect(analyzeCodeIndent(text, selectionStart, selectionEnd, unindent)).toEqual(expected);
});
});
@@ -1,8 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {findCodeBlockClosing} from '@app/features/lexical/composer/markdownSpans';
const INDENT = '\t';
const SPACE_INDENT_WIDTH = 4;
const FENCE_RE = /^ {0,3}`{3,}/;
const FENCE = '```';
const LEADING_SPACE_RE = /^[ \t\r]+/;
const TRAILING_SPACE_RE = /[ \t\r]+$/;
const FENCE_LANGUAGE_RE = /^[\w+.#/-]+[ \t\r]*$/;
const LIST_ITEM_RE = /^( *)(?:[-*]|\d+\.) /;
const INVISIBLE_RE =
/[\p{White_Space}\0\u00ad\u061c\u115f\u1160\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2069\u2800\u3164\ufeff\uffa0]|\u034f|\u17b4|\u17b5|[\ufe00-\ufe0f]|[\u{e0100}-\u{e01ef}]/gu;
export interface CodeIndentEdit {
start: number;
@@ -16,6 +24,40 @@ export interface CodeIndentPlan {
selectionEnd: number;
}
interface CodeBlockBody {
start: number;
end: number;
rendered: boolean;
}
interface SourceLine {
text: string;
offset: number;
}
interface LinePoint {
index: number;
column: number;
}
interface ListItem {
level: number;
content: string;
}
interface CodeBlockParse {
body: CodeBlockBody | null;
resume: LinePoint | null;
}
interface ScanScope {
bodies: Array<CodeBlockBody>;
end: number;
quotes: boolean;
multilineQuotes: boolean;
rendered: boolean;
}
interface PhysicalLine {
start: number;
end: number;
@@ -23,23 +65,329 @@ interface PhysicalLine {
isCodeContent: boolean;
}
function scanLines(text: string): Array<PhysicalLine> {
const lines: Array<PhysicalLine> = [];
let open = false;
let pos = 0;
for (const content of text.split('\n')) {
const fenceMatch = FENCE_RE.exec(content);
const rest = fenceMatch == null ? '' : content.slice(fenceMatch[0].length);
const isFence = fenceMatch != null && (!open || /^\s*$/.test(rest) || rest.includes('```'));
lines.push({start: pos, end: pos + content.length, text: content, isCodeContent: !isFence && open});
if (isFence) {
open = !open;
function trimStart(text: string): string {
return text.replace(LEADING_SPACE_RE, '');
}
function trimEnd(text: string): string {
return text.replace(TRAILING_SPACE_RE, '');
}
function hasVisibleContent(text: string): boolean {
return text.replace(INVISIBLE_RE, '') !== '';
}
function hasOpenInlineCode(text: string): boolean {
let open = 0;
for (const [run] of text.matchAll(/`+/g)) {
if (open === 0) {
open = run.length;
} else if (open === run.length) {
open = 0;
}
pos += content.length + 1;
}
return open > 0;
}
function countLeadingSpaces(text: string): number {
let spaces = 0;
while (text[spaces] === ' ') {
spaces += 1;
}
return spaces;
}
function matchListItem(text: string): ListItem | null {
const match = LIST_ITEM_RE.exec(text);
if (match == null || match[1]!.length === 1) {
return null;
}
return {level: match[1]!.length >> 1, content: text.slice(match[0]!.length)};
}
function isBulletPointText(text: string): boolean {
return matchListItem(text) == null && trimStart(text).startsWith('- ') && !text.startsWith(' ');
}
function startsBlockquote(trimmed: string, scope: ScanScope): boolean {
return (scope.multilineQuotes && trimmed.startsWith('>>> ')) || (scope.quotes && trimmed.startsWith('> '));
}
function findOpeningFence(text: string): number {
for (let fence = text.indexOf(FENCE); fence >= 0; fence = text.indexOf(FENCE, fence + 1)) {
let backslashes = 0;
while (text[fence - backslashes - 1] === '\\') {
backslashes += 1;
}
if (backslashes % 2 === 0) {
return hasOpenInlineCode(text.slice(0, fence)) ? -1 : fence;
}
}
return -1;
}
function splitLines(text: string): Array<SourceLine> {
const lines: Array<SourceLine> = [];
let offset = 0;
for (const line of text.split('\n')) {
lines.push({text: line, offset});
offset += line.length + 1;
}
return lines;
}
function linesEnd(lines: Array<SourceLine>): number {
const last = lines[lines.length - 1]!;
return last.offset + last.text.length;
}
function trimLineWindow(lines: Array<SourceLine>): Array<SourceLine> {
let start = 0;
let end = lines.length;
while (start < end && trimStart(lines[start]!.text) === '') {
start += 1;
}
while (end > start && trimStart(lines[end - 1]!.text) === '') {
end -= 1;
}
const window = lines.slice(start, end);
if (window.length === 0) {
return window;
}
const first = window[0]!;
const trimmed = trimStart(first.text);
window[0] = {text: trimmed, offset: first.offset + first.text.length - trimmed.length};
const last = window[window.length - 1]!;
window[window.length - 1] = {text: trimEnd(last.text), offset: last.offset};
return window;
}
function opensUnclosedBlock(line: SourceLine, column: number, info: string): boolean {
return (
column === line.text.length - trimStart(line.text).length || FENCE_LANGUAGE_RE.test(info) || trimStart(info) === ''
);
}
function resumeAt(lines: Array<SourceLine>, {index, column}: LinePoint): number {
const line = lines[index]!;
if (column >= line.text.length) {
return index + 1;
}
lines[index] = {text: line.text.slice(column), offset: line.offset + column};
return index;
}
function resumeIndex(lines: Array<SourceLine>, {index, column}: LinePoint): number {
return column >= lines[index]!.text.length ? index + 1 : index;
}
function parseCodeBlock(lines: Array<SourceLine>, index: number, column: number, scope: ScanScope): CodeBlockParse {
const line = lines[index]!;
let infoStart = column;
while (line.text[infoStart] === '`') {
infoStart += 1;
}
const fence = line.text.slice(column, infoStart);
const start = line.offset + infoStart;
const info = line.text.slice(infoStart);
const inlineEnd = info.indexOf(fence);
if (inlineEnd >= 0) {
const visible = hasVisibleContent(info.slice(0, inlineEnd));
return {
body: {start, end: start + inlineEnd, rendered: visible && scope.rendered},
resume: visible ? {index, column: infoStart + inlineEnd + fence.length} : null,
};
}
let content = FENCE_LANGUAGE_RE.test(info) ? '' : info;
for (let next = index + 1; next < lines.length; next += 1) {
const text = lines[next]!.text;
const closing = findCodeBlockClosing(text, fence, fence.length);
if (closing == null) {
content += text;
continue;
}
const visible = hasVisibleContent(content + text.slice(0, closing.fenceIndex));
const trailingStart = closing.fenceIndex + (closing.trailing.length > 0 ? closing.runLength : fence.length);
return {
body: {start, end: lines[next]!.offset + closing.fenceIndex, rendered: visible && scope.rendered},
resume: visible ? {index: next, column: trailingStart} : null,
};
}
if (!opensUnclosedBlock(line, column, info)) {
return {body: null, resume: null};
}
return {body: {start, end: scope.end, rendered: false}, resume: null};
}
function scanParagraph(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
let next = index + 1;
while (next < lines.length) {
const trimmed = trimStart(lines[next]!.text);
if (
trimmed === '' ||
trimmed.startsWith(FENCE) ||
matchListItem(trimmed) != null ||
startsBlockquote(trimmed, scope)
) {
return next;
}
const fence = findOpeningFence(lines[next]!.text);
if (fence >= 0) {
const parsed = parseCodeBlock(lines, next, fence, scope);
if (parsed.resume != null) {
return next;
}
if (parsed.body != null) {
scope.bodies.push(parsed.body);
}
}
next += 1;
}
return next;
}
function scanQuote(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
const quoted: Array<SourceLine> = [];
let next = index;
while (next < lines.length) {
const line = lines[next]!;
const trimmed = trimStart(line.text);
if (!trimmed.startsWith('> ')) {
break;
}
quoted.push({text: trimmed.slice(2), offset: line.offset + line.text.length - trimmed.length + 2});
next += 1;
}
scanBlocks(quoted, {...scope, quotes: false, end: linesEnd(quoted)});
return next;
}
function scanSpoiler(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
const line = lines[index]!;
const open = line.text.indexOf('||') + 2;
const children: Array<SourceLine> = [{text: line.text.slice(open), offset: line.offset + open}];
for (let next = index + 1; next < lines.length; next += 1) {
const close = lines[next]!.text.indexOf('||');
if (close < 0) {
children.push(lines[next]!);
continue;
}
children.push({text: lines[next]!.text.slice(0, close), offset: lines[next]!.offset});
if (!children.some((child) => hasVisibleContent(child.text))) {
return next + 1;
}
const window = trimLineWindow(children);
scanBlocks(window, {...scope, end: linesEnd(window)});
return resumeAt(lines, {index: next, column: close + 2});
}
scanBlocks(children, {...scope, rendered: false, end: linesEnd(children)});
return lines.length;
}
function scanListFence(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
if (index >= lines.length) {
return index;
}
const line = lines[index]!;
const trimmed = trimStart(line.text);
if (!trimmed.startsWith(FENCE)) {
return index;
}
const parsed = parseCodeBlock(lines, index, line.text.length - trimmed.length, scope);
if (parsed.body == null || parsed.resume == null) {
return index;
}
scope.bodies.push(parsed.body);
return resumeIndex(lines, parsed.resume);
}
function scanList(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
const level = matchListItem(lines[index]!.text)?.level ?? 0;
let next = index;
while (next < lines.length) {
const text = lines[next]!.text;
const trimmed = trimStart(text);
if (trimmed.startsWith('#') || startsBlockquote(trimmed, scope)) {
return next;
}
const item = matchListItem(text);
if (item != null) {
next = matchListItem(item.content) == null ? scanListFence(lines, next + 1, scope) : next + 1;
continue;
}
if (!isBulletPointText(text) && countLeadingSpaces(text) <= level * 2) {
return next;
}
next += 1;
}
return next;
}
function scanBlock(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
const line = lines[index]!;
const trimmed = trimStart(line.text);
if (trimmed.startsWith('>>> ')) {
if (!scope.multilineQuotes) {
return index + 1;
}
const quoted = {text: trimmed.slice(4), offset: line.offset + line.text.length - trimmed.length + 4};
scanBlocks([quoted, ...lines.slice(index + 1)], {...scope, quotes: true, multilineQuotes: false});
return lines.length;
}
if (trimmed.startsWith('> ')) {
return scope.quotes ? scanQuote(lines, index, scope) : scanParagraph(lines, index, scope);
}
if (matchListItem(line.text) != null) {
return scanList(lines, index, scope);
}
if (trimmed.startsWith('||') && !trimmed.includes('||', 2)) {
return scanSpoiler(lines, index, scope);
}
const fence = findOpeningFence(line.text);
if (fence >= 0) {
const parsed = parseCodeBlock(lines, index, fence, scope);
if (parsed.body != null) {
scope.bodies.push(parsed.body);
}
if (parsed.resume != null) {
return resumeAt(lines, parsed.resume);
}
}
return scanParagraph(lines, index, scope);
}
function scanBlocks(lines: Array<SourceLine>, scope: ScanScope): void {
let index = 0;
while (index < lines.length) {
index = trimStart(lines[index]!.text) === '' ? index + 1 : scanBlock(lines, index, scope);
}
}
export function scanCodeBlocks(text: string): Array<CodeBlockBody> {
const scope: ScanScope = {bodies: [], end: text.length, quotes: true, multilineQuotes: true, rendered: true};
scanBlocks(splitLines(text), scope);
return scope.bodies;
}
export function isOffsetInsideCodeBlock(text: string, offset: number): boolean {
return scanCodeBlocks(text).some((body) => body.start <= offset && offset <= body.end);
}
function scanLines(text: string): Array<PhysicalLine> {
const bodies = scanCodeBlocks(text);
return splitLines(text).map(({text: content, offset: start}) => {
const end = start + content.length;
return {
start,
end,
text: content,
isCodeContent: bodies.some(
(body) => start >= body.start && (end <= body.end || trimStart(text.slice(start, body.end)) !== ''),
),
};
});
}
function lineAt(lines: Array<PhysicalLine>, offset: number): number {
for (let i = 0; i < lines.length; i += 1) {
if (offset >= lines[i]!.start && offset <= lines[i]!.end) {
@@ -1,12 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {$createComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {
$createComposerCustomEmojiNode,
$isComposerCustomEmojiNode,
} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {
$createComposerMentionNode,
$isComposerMentionNode,
type ComposerMentionType,
} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {$createComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {$createComposerStandardEmojiNode} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode';
import {
$createComposerPlainSegmentNode,
$isComposerPlainSegmentNode,
} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {
$createComposerStandardEmojiNode,
$isComposerStandardEmojiNode,
} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode';
import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import {
$createParagraphNode,
@@ -209,6 +219,24 @@ export function $getComposerDisplayText(): string {
return $buildDisplayLayout().text;
}
export function $getComposerScanText(): string {
const {text, leaves} = $buildDisplayLayout();
let scanText = '';
let offset = 0;
for (const {node, start, end} of leaves) {
if (
$isComposerMentionNode(node) ||
$isComposerCustomEmojiNode(node) ||
$isComposerStandardEmojiNode(node) ||
$isComposerPlainSegmentNode(node)
) {
scanText += text.slice(offset, start) + 'x'.repeat(end - start);
offset = end;
}
}
return scanText + text.slice(offset);
}
function $pointAtDisplayOffset(layout: DisplayLayout, offset: number): DisplayPoint {
const clampedOffset = Math.min(Math.max(0, offset), layout.text.length);
for (const leaf of layout.leaves) {
@@ -658,7 +658,7 @@ function trimCodeFenceInfo(value: string): string {
return value.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '');
}
function findCodeBlockClosing(
export function findCodeBlockClosing(
line: string,
fence: string,
fenceLength: number,