From a5af857564622196e19d18d6c4207e6830e8022a Mon Sep 17 00:00:00 2001 From: Hampus Date: Fri, 11 Sep 2026 13:20:26 +0200 Subject: [PATCH] fix(app): insert a newline on Enter inside code blocks (#2674) --- .../composer/ComposerCodeIndent.test.ts | 44 +++ .../lexical/composer/ComposerCodeIndent.ts | 4 +- .../lexical/composer/ComposerEnter.test.ts | 181 +++++++++ .../lexical/composer/ComposerEnter.ts | 57 +++ .../lexical/composer/LexicalComposerInput.tsx | 37 +- .../lexical/composer/codeBlockIndent.test.ts | 369 +++++++++++++++++ .../lexical/composer/codeBlockIndent.ts | 374 +++++++++++++++++- .../lexical/composer/composerOffsets.ts | 34 +- .../lexical/composer/markdownSpans.ts | 2 +- 9 files changed, 1052 insertions(+), 50 deletions(-) create mode 100644 fluxer_app/src/features/lexical/composer/ComposerCodeIndent.test.ts create mode 100644 fluxer_app/src/features/lexical/composer/ComposerEnter.test.ts create mode 100644 fluxer_app/src/features/lexical/composer/ComposerEnter.ts create mode 100644 fluxer_app/src/features/lexical/composer/codeBlockIndent.test.ts diff --git a/fluxer_app/src/features/lexical/composer/ComposerCodeIndent.test.ts b/fluxer_app/src/features/lexical/composer/ComposerCodeIndent.test.ts new file mode 100644 index 000000000..36e345b28 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/ComposerCodeIndent.test.ts @@ -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'); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/ComposerCodeIndent.ts b/fluxer_app/src/features/lexical/composer/ComposerCodeIndent.ts index 02f0fcae4..d3f073808 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerCodeIndent.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerCodeIndent.ts @@ -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; } diff --git a/fluxer_app/src/features/lexical/composer/ComposerEnter.test.ts b/fluxer_app/src/features/lexical/composer/ComposerEnter.test.ts new file mode 100644 index 000000000..6059eedbc --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/ComposerEnter.test.ts @@ -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>; + 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().append(slot); + slot.ensurePlaceholder(); + $selectComposerRange(offset, offset); + }); +} + +function slotValidity(editor: LexicalEditor): string { + return editor.read(() => + $getRoot().getFirstChildOrThrow().getLastChildOrThrow().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); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/ComposerEnter.ts b/fluxer_app/src/features/lexical/composer/ComposerEnter.ts new file mode 100644 index 000000000..5658923f6 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/ComposerEnter.ts @@ -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, + ); +} diff --git a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx index be22177db..f6ee83f05 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx @@ -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) => { diff --git a/fluxer_app/src/features/lexical/composer/codeBlockIndent.test.ts b/fluxer_app/src/features/lexical/composer/codeBlockIndent.test.ts new file mode 100644 index 000000000..a8594a7d4 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/codeBlockIndent.test.ts @@ -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 = [ + ['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]; + +const BODY_ROWS: Array = [ + ['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 = [ + ['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 { + 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): Array { + 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 { + return collectCodeBlocks(parseMarkdownAstWithWasm(text, DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes).map((node) => + withoutQuoteMarkersOrWhitespace(`${node.language ?? ''}${node.content}`), + ); +} + +function scannedCodeBlocks(text: string): Array { + 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(); + 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); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts b/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts index 4f9fbd71d..2179c5fcd 100644 --- a/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts +++ b/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts @@ -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; + 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 { - const lines: Array = []; - 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 { + const lines: Array = []; + 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): number { + const last = lines[lines.length - 1]!; + return last.offset + last.text.length; +} + +function trimLineWindow(lines: Array): Array { + 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, {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, {index, column}: LinePoint): number { + return column >= lines[index]!.text.length ? index + 1 : index; +} + +function parseCodeBlock(lines: Array, 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, 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, index: number, scope: ScanScope): number { + const quoted: Array = []; + 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, index: number, scope: ScanScope): number { + const line = lines[index]!; + const open = line.text.indexOf('||') + 2; + const children: Array = [{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, 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, 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, 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, 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 { + 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 { + 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, offset: number): number { for (let i = 0; i < lines.length; i += 1) { if (offset >= lines[i]!.start && offset <= lines[i]!.end) { diff --git a/fluxer_app/src/features/lexical/composer/composerOffsets.ts b/fluxer_app/src/features/lexical/composer/composerOffsets.ts index e50df67f9..888a1fd3a 100644 --- a/fluxer_app/src/features/lexical/composer/composerOffsets.ts +++ b/fluxer_app/src/features/lexical/composer/composerOffsets.ts @@ -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) { diff --git a/fluxer_app/src/features/lexical/composer/markdownSpans.ts b/fluxer_app/src/features/lexical/composer/markdownSpans.ts index cae2663e2..751fb4739 100644 --- a/fluxer_app/src/features/lexical/composer/markdownSpans.ts +++ b/fluxer_app/src/features/lexical/composer/markdownSpans.ts @@ -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,