From a98d8ef67922b1f76617a73e9259dc4f52bf5506 Mon Sep 17 00:00:00 2001 From: Hampus Date: Fri, 11 Sep 2026 13:22:03 +0200 Subject: [PATCH] fix(app): wrap multiline selections in code blocks (#2675) --- .../lexical/composer/codeBlockIndent.ts | 6 +- .../lexical/composer/codeBlockWrap.test.ts | 608 ++++++++++++++++++ .../lexical/composer/codeBlockWrap.ts | 138 ++++ .../lexical/composer/composerOffsets.ts | 38 +- 4 files changed, 785 insertions(+), 5 deletions(-) create mode 100644 fluxer_app/src/features/lexical/composer/codeBlockWrap.test.ts create mode 100644 fluxer_app/src/features/lexical/composer/codeBlockWrap.ts diff --git a/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts b/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts index 2179c5fcd..b42386c9a 100644 --- a/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts +++ b/fluxer_app/src/features/lexical/composer/codeBlockIndent.ts @@ -7,7 +7,7 @@ const SPACE_INDENT_WIDTH = 4; const FENCE = '```'; const LEADING_SPACE_RE = /^[ \t\r]+/; const TRAILING_SPACE_RE = /[ \t\r]+$/; -const FENCE_LANGUAGE_RE = /^[\w+.#/-]+[ \t\r]*$/; +export 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; @@ -24,7 +24,7 @@ export interface CodeIndentPlan { selectionEnd: number; } -interface CodeBlockBody { +export interface CodeBlockBody { start: number; end: number; rendered: boolean; @@ -73,7 +73,7 @@ function trimEnd(text: string): string { return text.replace(TRAILING_SPACE_RE, ''); } -function hasVisibleContent(text: string): boolean { +export function hasVisibleContent(text: string): boolean { return text.replace(INVISIBLE_RE, '') !== ''; } diff --git a/fluxer_app/src/features/lexical/composer/codeBlockWrap.test.ts b/fluxer_app/src/features/lexical/composer/codeBlockWrap.test.ts new file mode 100644 index 000000000..e6eced11d --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/codeBlockWrap.test.ts @@ -0,0 +1,608 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {registerComposerMarkdownHighlight} from '@app/features/lexical/composer/ComposerMarkdownHighlight'; +import {type CodeBlockWrapPlan, planCodeBlockWrap} from '@app/features/lexical/composer/codeBlockWrap'; +import { + $captureSelectionOffsets, + $getComposerDisplayText, + $isComposerSelectionWrapped, + $replaceComposerRange, + $selectComposerRange, + $wrapComposerSelection, +} from '@app/features/lexical/composer/composerOffsets'; +import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans'; +import { + $isComposerCustomEmojiNode, + ComposerCustomEmojiNode, +} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode'; +import {$isComposerMentionNode, ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode'; +import {$isSyntaxMarkerNode, SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode'; +import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasm'; +import type {CodeBlockNode, Node} from '@app/features/messaging/utils/markdown/parser/Nodes'; +import {createEmptyHistoryState, registerHistory} from '@lexical/history'; +import { + $getNodeByKey, + $getRoot, + createEditor, + type ElementNode, + type LexicalEditor, + type NodeKey, + REDO_COMMAND, + UNDO_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})); + +interface PlanResult { + wrapped: boolean; + text: string; + selection: [number, number]; +} + +type PlanRow = [label: string, text: string, start: number, end: number, expected: PlanResult | null]; + +function applyPlan( + text: string, + {wrapped, opening, closing, selectionStart, selectionEnd}: CodeBlockWrapPlan, +): PlanResult { + const head = text.slice(0, opening.start); + const middle = text.slice(opening.end, closing.start); + return { + wrapped, + text: `${head}${opening.text}${middle}${closing.text}${text.slice(closing.end)}`, + selection: [selectionStart, selectionEnd], + }; +} + +function planned(text: string, start: number, end: number): PlanResult | null { + const plan = planCodeBlockWrap(text, start, end); + return plan == null ? null : applyPlan(text, plan); +} + +const PLAN_ROWS: Array = [ + ['keeps inline code for a single-line selection', 'say hello there', 4, 9, null], + ['keeps inline code for a single-line selection beside a block', '```\na\n```\nhello there', 10, 15, null], + ['keeps inline code for a collapsed caret', 'a\nb', 1, 1, null], + ['keeps inline code for one line inside a block', '```\na\nb\n```', 4, 5, null], + ['declines a selection of newlines only', 'a\n\nb', 1, 3, null], + ['declines part of a block body', '```\na\nb\nc\n```', 4, 7, null], + ['declines a selection that ends inside a block', 'x\n```\na\nb\n```', 0, 7, null], + ['declines a block whose opening line holds content rather than a language', '```a b\nc\nd\n```', 7, 10, null], + ['declines a fenced block inside a quote', '> ```\n> a\n> b\n> ```', 6, 13, null], + ['declines a block inside a spoiler that never closed', '||\n```\na\nb\n```', 7, 10, null], + ['declines whitespace-only lines the parser would not render as a block', ' \n ', 0, 3, null], + ['declines a selection across a block, text and another block', '```\na\n```\nmid\n```\nb\n```', 4, 19, null], + [ + 'wraps a multiline selection inside a block that is still open', + '```\na\nb', + 4, + 7, + {wrapped: false, text: '```\n```\na\nb\n```', selection: [8, 11]}, + ], + ['wraps a whole two-line message', 'a\nb', 0, 3, {wrapped: false, text: '```\na\nb\n```', selection: [4, 7]}], + [ + 'wraps whole lines in the middle without inserting breaks', + 'x\na\nb\ny', + 2, + 5, + {wrapped: false, text: 'x\n```\na\nb\n```\ny', selection: [6, 9]}, + ], + [ + 'inserts a break before the opening fence when the selection starts mid-line', + 'hi a\nb', + 3, + 6, + {wrapped: false, text: 'hi \n```\na\nb\n```', selection: [8, 11]}, + ], + [ + 'inserts a break after the closing fence when the selection ends mid-line', + 'a\nb bye', + 0, + 3, + {wrapped: false, text: '```\na\nb\n```\n bye', selection: [4, 7]}, + ], + [ + 'inserts a break at both edges when the selection starts and ends mid-line', + 'hi a\nb bye', + 3, + 6, + {wrapped: false, text: 'hi \n```\na\nb\n```\n bye', selection: [8, 11]}, + ], + [ + 'keeps a selected trailing newline outside the block', + 'a\nb\nc', + 0, + 4, + {wrapped: false, text: '```\na\nb\n```\nc', selection: [4, 7]}, + ], + [ + 'keeps a selected leading newline outside the block', + 'x\na\nb', + 1, + 5, + {wrapped: false, text: 'x\n```\na\nb\n```', selection: [6, 9]}, + ], + [ + 'keeps several selected edge newlines outside the block', + 'x\n\na\nb\n\ny', + 1, + 8, + {wrapped: false, text: 'x\n\n```\na\nb\n```\n\ny', selection: [7, 10]}, + ], + [ + 'treats one line with its selected line break as a block', + 'foo\nbar', + 0, + 4, + {wrapped: false, text: '```\nfoo\n```\nbar', selection: [4, 7]}, + ], + [ + 'wraps the lines between two blocks', + '```\nA\n```\nB\nC\n```\nD\n```', + 10, + 13, + {wrapped: false, text: '```\nA\n```\n```\nB\nC\n```\n```\nD\n```', selection: [14, 17]}, + ], + [ + 'wraps a multiline selection of inline code that never closed', + '`a\nb`', + 0, + 5, + {wrapped: false, text: '```\n`a\nb`\n```', selection: [4, 9]}, + ], + [ + 'wraps two whole blocks in a longer fence', + '```\na\n```\n```\nb\n```', + 0, + 19, + {wrapped: false, text: '````\n```\na\n```\n```\nb\n```\n````', selection: [5, 24]}, + ], + [ + 'wraps content that already holds a fence in a longer fence', + 'x\n```\ny\n```\nz', + 0, + 13, + {wrapped: false, text: '````\nx\n```\ny\n```\nz\n````', selection: [5, 18]}, + ], + [ + 'unwraps when the block content is selected', + '```\na\nb\n```', + 4, + 7, + {wrapped: true, text: 'a\nb', selection: [0, 3]}, + ], + [ + 'unwraps a block and leaves the text around it alone', + 'x\n```\na\nb\n```\ny', + 6, + 9, + {wrapped: true, text: 'x\na\nb\ny', selection: [2, 5]}, + ], + [ + 'unwraps a block that is followed by a line the wrap kept outside', + '```\na\nb\n```\nc', + 4, + 7, + {wrapped: true, text: 'a\nb\nc', selection: [0, 3]}, + ], + [ + 'unwraps a block whose content is a single line', + '```\nfoo\n```\nbar', + 4, + 7, + {wrapped: true, text: 'foo\nbar', selection: [0, 3]}, + ], + [ + 'unwraps when the fences are selected too', + '```\na\nb\n```', + 0, + 11, + {wrapped: true, text: 'a\nb', selection: [0, 3]}, + ], + [ + 'unwraps when the fences and the newlines around them are selected', + 'x\n```\na\nb\n```\ny', + 1, + 14, + {wrapped: true, text: 'x\na\nb\ny', selection: [2, 5]}, + ], + ['unwraps a block with a language fence', '```js\na\nb\n```', 6, 9, {wrapped: true, text: 'a\nb', selection: [0, 3]}], + [ + 'unwraps a block that follows an escaped fence', + '\\```\n```\na\nb\n```', + 9, + 12, + {wrapped: true, text: '\\```\na\nb', selection: [5, 8]}, + ], + [ + 'unwraps a block written with a longer fence', + '````\nx\n```\ny\n```\nz\n````', + 5, + 18, + {wrapped: true, text: 'x\n```\ny\n```\nz', selection: [0, 13]}, + ], + [ + 'unwraps a block whose closing fence has trailing spaces', + '```\na\nb\n``` ', + 4, + 7, + {wrapped: true, text: 'a\nb', selection: [0, 3]}, + ], + ['declines a block whose closing fence is longer than its opening fence', '```\na\nb\n`````', 4, 7, null], + ['declines a block whose opening fence is followed by spaces', '``` \na\nb\n```', 6, 9, null], +]; + +const ROUND_TRIP_ROWS: Array<[text: string, start: number, end: number]> = [ + ['a\nb', 0, 3], + ['x\na\nb\ny', 2, 5], + ['a\nb\nc', 0, 4], + ['x\na\nb', 1, 5], + ['x\n\na\nb\n\ny', 1, 8], + ['foo\nbar', 0, 4], + ['```\nA\n```\nB\nC\n```\nD\n```', 10, 13], + ['`a\nb`', 0, 5], + ['```\na\n```\n```\nb\n```', 0, 19], + ['x\n```\ny\n```\nz', 0, 13], + ['```\na\nb', 4, 7], + ['> a\n> b', 0, 7], +]; + +const PARSER_ROWS: Array<[text: string, start: number, end: number, content: string]> = [ + ['a\nb', 0, 3, 'a\nb\n'], + ['hi a\nb bye', 3, 6, 'a\nb\n'], + ['```\na\nb', 4, 7, 'a\nb\n'], + ['x\n```\ny\n```\nz', 0, 13, 'x\n```\ny\n```\nz\n'], + ['> a\n> b', 0, 7, '> a\n> b\n'], +]; + +function createComposer(): LexicalEditor { + const editor = createEditor({ + namespace: 'code-block-wrap-test', + nodes: [ComposerCustomEmojiNode, ComposerMentionNode, SyntaxMarkerNode], + onError: (error) => { + throw error; + }, + }); + registerComposerMarkdownHighlight(editor); + return editor; +} + +function seeded(text: string, anchor: number, focus: number): LexicalEditor { + const editor = createComposer(); + editor.update( + () => { + $replaceComposerRange(0, 0, {kind: 'text', text}, {leading: false, trailing: false}); + $selectComposerRange(anchor, focus); + }, + {discrete: true}, + ); + return editor; +} + +function clickFormat(editor: LexicalEditor, wrapper: string): void { + editor.update(() => $wrapComposerSelection(wrapper, wrapper), {discrete: true}); +} + +function composerState(editor: LexicalEditor): { + text: string; + selection: {anchor: number; focus: number} | null; + pressed: boolean; +} { + return editor.read(() => ({ + text: $getComposerDisplayText(), + selection: $captureSelectionOffsets(), + pressed: $isComposerSelectionWrapped('`', '`'), + })); +} + +function atomicKeys(editor: LexicalEditor): Array { + return editor.read(() => + $getRoot() + .getFirstChildOrThrow() + .getChildren() + .filter((node) => $isComposerMentionNode(node) || $isComposerCustomEmojiNode(node)) + .map((node) => node.getKey()), + ); +} + +function atomicState(editor: LexicalEditor, keys: Array): Array<{attached: boolean; literal: boolean} | null> { + return editor.read(() => + keys.map((key) => { + const node = $getNodeByKey(key); + if (!$isComposerMentionNode(node) && !$isComposerCustomEmojiNode(node)) { + return null; + } + return {attached: node.isAttached(), literal: node.isLiteral()}; + }), + ); +} + +function markerTexts(editor: LexicalEditor): Array { + return editor.read(() => + $getRoot() + .getAllTextNodes() + .filter($isSyntaxMarkerNode) + .map((node) => node.getTextContent()), + ); +} + +describe('planCodeBlockWrap', () => { + it.each(PLAN_ROWS)('%s', (_label, text, start, end, expected) => { + expect(planned(text, start, end)).toEqual(expected); + }); + + it.each(ROUND_TRIP_ROWS)('round trips %j selected from %i to %i', (text, start, end) => { + const wrapped = planned(text, start, end); + const unwrapped = wrapped == null ? null : planned(wrapped.text, ...wrapped.selection); + expect({wrapped: unwrapped?.wrapped, text: unwrapped?.text}).toEqual({wrapped: true, text}); + }); + + it('keeps the breaks a mid-line wrap inserted when the block is unwrapped again', () => { + const wrapped = planned('hi a\nb bye', 3, 6); + expect(wrapped).toEqual({wrapped: false, text: 'hi \n```\na\nb\n```\n bye', selection: [8, 11]}); + expect(wrapped == null ? null : planned(wrapped.text, ...wrapped.selection)).toEqual({ + wrapped: true, + text: 'hi \na\nb\n bye', + selection: [4, 7], + }); + }); +}); + +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 parsedCodeBlocks(text: string): Array { + return collectCodeBlocks(parseMarkdownAstWithWasm(text, DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes).map( + (node) => node.content, + ); +} + +describe('planCodeBlockWrap against the markdown parser', () => { + it.each(PARSER_ROWS)('wraps %j from %i to %i into one block the parser renders', (text, start, end, content) => { + expect(parsedCodeBlocks(planned(text, start, end)?.text ?? '')).toEqual([content]); + }); +}); + +describe('the composer code option', () => { + it('wraps a mid-line multiline selection in fence lines', () => { + const editor = seeded('hi a\nb bye', 3, 6); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({ + text: 'hi \n```\na\nb\n```\n bye', + selection: {anchor: 8, focus: 11}, + pressed: true, + }); + expect(markerTexts(editor)).toEqual(['```', '```']); + }); + + it('restores the text when it is used twice on whole lines', () => { + const editor = seeded('x\na\nb\ny', 2, 5); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({ + text: 'x\n```\na\nb\n```\ny', + selection: {anchor: 6, focus: 9}, + pressed: true, + }); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({text: 'x\na\nb\ny', selection: {anchor: 2, focus: 5}, pressed: false}); + }); + + it('keeps a backward selection backward through both clicks', () => { + const editor = seeded('a\nb', 3, 0); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({ + text: '```\na\nb\n```', + selection: {anchor: 7, focus: 4}, + pressed: true, + }); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({text: 'a\nb', selection: {anchor: 3, focus: 0}, pressed: false}); + }); + + it('unwraps a block whose fences are selected as well', () => { + const editor = seeded('```\na\nb\n```', 0, 11); + expect(composerState(editor).pressed).toBe(true); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({text: 'a\nb', selection: {anchor: 0, focus: 3}, pressed: false}); + }); + + it('unwraps a block with a language fence', () => { + const editor = seeded('```js\na\nb\n```', 6, 9); + expect(composerState(editor).pressed).toBe(true); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({text: 'a\nb', selection: {anchor: 0, focus: 3}, pressed: false}); + }); + + it('still writes inline code for a single-line selection', () => { + const editor = seeded('say hello there', 4, 9); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({ + text: 'say `hello` there', + selection: {anchor: 5, focus: 10}, + pressed: true, + }); + }); + + it('still leaves other wrappers symmetric on a multiline selection', () => { + const editor = seeded('a\nb', 0, 3); + clickFormat(editor, '**'); + expect(composerState(editor)).toEqual({text: '**a\nb**', selection: {anchor: 2, focus: 5}, pressed: false}); + }); + + it('wraps a multiline selection inside a block that is still open', () => { + const editor = seeded('```\na\nb', 4, 7); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({ + text: '```\n```\na\nb\n```', + selection: {anchor: 8, focus: 11}, + pressed: true, + }); + }); + + it('makes no change for a multiline selection inside a block', () => { + const editor = seeded('```\na\nb\nc\n```', 4, 7); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({ + text: '```\na\nb\nc\n```', + selection: {anchor: 4, focus: 7}, + pressed: false, + }); + }); + + it('makes no change for a multiline selection inside a quoted block', () => { + const editor = seeded('> ```\n> a\n> b\n> ```', 6, 13); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({ + text: '> ```\n> a\n> b\n> ```', + selection: {anchor: 6, focus: 13}, + pressed: false, + }); + }); + + it('makes no change for whitespace-only lines however often it is used', () => { + const editor = seeded(' \n ', 0, 3); + clickFormat(editor, '`'); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({text: ' \n ', selection: {anchor: 0, focus: 3}, pressed: false}); + }); + + it('removes single backticks left around a multiline selection', () => { + const editor = seeded('`a\nb`', 1, 4); + expect(composerState(editor).pressed).toBe(true); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({text: 'a\nb', selection: {anchor: 0, focus: 3}, pressed: false}); + }); + + it('undoes the unwrap in one step', async () => { + const editor = createComposer(); + const history = createEmptyHistoryState(); + registerHistory(editor, history, 300); + editor.update( + () => { + $replaceComposerRange(0, 0, {kind: 'text', text: '```\na\nb\n```'}, {leading: false, trailing: false}); + $selectComposerRange(4, 7); + }, + {discrete: true}, + ); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({text: 'a\nb', selection: {anchor: 0, focus: 3}, pressed: false}); + expect(history.undoStack).toHaveLength(1); + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + expect(composerState(editor)).toEqual({ + text: '```\na\nb\n```', + selection: {anchor: 4, focus: 7}, + pressed: true, + }); + }); + + it('keeps mention and emoji nodes when it wraps and unwraps them', () => { + const editor = createComposer(); + editor.update( + () => { + $replaceComposerRange( + 0, + 0, + {kind: 'mention', mentionType: 'user', id: '1', display: '@alice', wire: '<@1>'}, + {trailing: false}, + ); + $replaceComposerRange(6, 6, {kind: 'text', text: '\n'}, {leading: false, trailing: false}); + $replaceComposerRange( + 7, + 7, + {kind: 'customEmoji', emojiId: '2', animated: false, display: ':blob:', wire: '<:blob:2>'}, + {trailing: false}, + ); + $selectComposerRange(0, 13); + }, + {discrete: true}, + ); + const keys = atomicKeys(editor); + expect(atomicState(editor, keys)).toEqual([ + {attached: true, literal: false}, + {attached: true, literal: false}, + ]); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({ + text: '```\n@alice\n:blob:\n```', + selection: {anchor: 4, focus: 17}, + pressed: true, + }); + expect(atomicState(editor, keys)).toEqual([ + {attached: true, literal: true}, + {attached: true, literal: true}, + ]); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({ + text: '@alice\n:blob:', + selection: {anchor: 0, focus: 13}, + pressed: false, + }); + expect(atomicState(editor, keys)).toEqual([ + {attached: true, literal: false}, + {attached: true, literal: false}, + ]); + }); + + it('reads a mention as filler so backticks in its name cannot hide the block', () => { + const editor = createComposer(); + editor.update( + () => { + $replaceComposerRange(0, 0, {kind: 'text', text: '```\n'}, {leading: false, trailing: false}); + $replaceComposerRange( + 4, + 4, + {kind: 'mention', mentionType: 'user', id: '1', display: '@```', wire: '<@1>'}, + {trailing: false}, + ); + $replaceComposerRange(8, 8, {kind: 'text', text: '\n```'}, {leading: false, trailing: false}); + $selectComposerRange(4, 8); + }, + {discrete: true}, + ); + expect(composerState(editor).pressed).toBe(true); + clickFormat(editor, '`'); + expect(composerState(editor)).toEqual({text: '@```', selection: {anchor: 0, focus: 4}, pressed: false}); + }); + + it('undoes the wrap in one step and redoes it', async () => { + const editor = createComposer(); + const history = createEmptyHistoryState(); + registerHistory(editor, history, 300); + editor.update( + () => { + $replaceComposerRange(0, 0, {kind: 'text', text: 'hi a\nb bye'}, {leading: false, trailing: false}); + $selectComposerRange(3, 6); + }, + {discrete: true}, + ); + clickFormat(editor, '`'); + expect(history.undoStack).toHaveLength(1); + editor.dispatchCommand(UNDO_COMMAND, undefined); + await Promise.resolve(); + expect(composerState(editor)).toEqual({ + text: 'hi a\nb bye', + selection: {anchor: 3, focus: 6}, + pressed: false, + }); + editor.dispatchCommand(REDO_COMMAND, undefined); + await Promise.resolve(); + expect(composerState(editor)).toEqual({ + text: 'hi \n```\na\nb\n```\n bye', + selection: {anchor: 8, focus: 11}, + pressed: true, + }); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/codeBlockWrap.ts b/fluxer_app/src/features/lexical/composer/codeBlockWrap.ts new file mode 100644 index 000000000..990678c5e --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/codeBlockWrap.ts @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type CodeBlockBody, + FENCE_LANGUAGE_RE, + hasVisibleContent, + scanCodeBlocks, +} from '@app/features/lexical/composer/codeBlockIndent'; + +const MIN_FENCE_LENGTH = 3; + +interface CodeBlockWrapEdit { + start: number; + end: number; + text: string; +} + +export interface CodeBlockWrapPlan { + wrapped: boolean; + opening: CodeBlockWrapEdit; + closing: CodeBlockWrapEdit; + selectionStart: number; + selectionEnd: number; +} + +interface FencedBlock { + start: number; + contentStart: number; + contentEnd: number; + end: number; +} + +function isLineStart(text: string, offset: number): boolean { + return offset === 0 || text[offset - 1] === '\n'; +} + +function isLineEnd(text: string, offset: number): boolean { + return offset === text.length || text[offset] === '\n'; +} + +function openingFenceFor(text: string, offset: number): string { + let start = offset; + while (text[start - 1] === '`') { + start -= 1; + } + return text.slice(start, offset); +} + +function closingFenceEnd(text: string, offset: number, fence: string): number { + let end = offset + fence.length; + if (text.slice(offset, end) !== fence) { + return -1; + } + while (text[end] === ' ' || text[end] === '\t' || text[end] === '\r') { + end += 1; + } + return isLineEnd(text, end) ? end : -1; +} + +function isInsideClosedBlock(text: string, bodies: ReadonlyArray, offset: number): boolean { + return bodies.some( + (body) => + body.start <= offset && offset <= body.end && text.startsWith(openingFenceFor(text, body.start), body.end), + ); +} + +function findFencedBlocks(text: string, bodies: ReadonlyArray): Array { + return bodies.flatMap((body) => { + const fence = openingFenceFor(text, body.start); + const start = body.start - fence.length; + const openingEnd = text.indexOf('\n', body.start); + const info = text.slice(body.start, openingEnd); + const end = closingFenceEnd(text, body.end, fence); + const fenced = + body.rendered && + end >= 0 && + isLineStart(text, start) && + text[body.end - 1] === '\n' && + (info === '' || FENCE_LANGUAGE_RE.test(info)); + return fenced ? [{start, contentStart: openingEnd + 1, contentEnd: body.end - 1, end}] : []; + }); +} + +function fenceFor(content: string): string { + let length = MIN_FENCE_LENGTH; + for (const [run] of content.matchAll(/`+/g)) { + length = Math.max(length, run.length + 1); + } + return '`'.repeat(length); +} + +export function planCodeBlockWrap(text: string, start: number, end: number): CodeBlockWrapPlan | null { + let contentStart = start; + while (contentStart < end && text[contentStart] === '\n') { + contentStart += 1; + } + let contentEnd = end; + while (contentEnd > contentStart && text[contentEnd - 1] === '\n') { + contentEnd -= 1; + } + if (contentStart === contentEnd) { + return null; + } + const bodies = scanCodeBlocks(text); + const block = findFencedBlocks(text, bodies).find( + (candidate) => + (candidate.contentStart === contentStart && candidate.contentEnd === contentEnd) || + (candidate.start === contentStart && candidate.end === contentEnd), + ); + if (block != null) { + return { + wrapped: true, + opening: {start: block.start, end: block.contentStart, text: ''}, + closing: {start: block.contentEnd, end: block.end, text: ''}, + selectionStart: block.start, + selectionEnd: block.start + block.contentEnd - block.contentStart, + }; + } + const content = text.slice(contentStart, contentEnd); + if ( + !text.slice(start, end).includes('\n') || + !hasVisibleContent(content) || + isInsideClosedBlock(text, bodies, contentStart) || + isInsideClosedBlock(text, bodies, contentEnd) + ) { + return null; + } + const fence = fenceFor(content); + const opening = `${isLineStart(text, contentStart) ? '' : '\n'}${fence}\n`; + const closing = `\n${fence}${isLineEnd(text, contentEnd) ? '' : '\n'}`; + return { + wrapped: false, + opening: {start: contentStart, end: contentStart, text: opening}, + closing: {start: contentEnd, end: contentEnd, text: closing}, + selectionStart: contentStart + opening.length, + selectionEnd: contentEnd + opening.length, + }; +} diff --git a/fluxer_app/src/features/lexical/composer/composerOffsets.ts b/fluxer_app/src/features/lexical/composer/composerOffsets.ts index 888a1fd3a..65c2b8170 100644 --- a/fluxer_app/src/features/lexical/composer/composerOffsets.ts +++ b/fluxer_app/src/features/lexical/composer/composerOffsets.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {type CodeBlockWrapPlan, planCodeBlockWrap} from '@app/features/lexical/composer/codeBlockWrap'; import { $createComposerCustomEmojiNode, $isComposerCustomEmojiNode, @@ -220,7 +221,10 @@ export function $getComposerDisplayText(): string { } export function $getComposerScanText(): string { - const {text, leaves} = $buildDisplayLayout(); + return scanTextFromLayout($buildDisplayLayout()); +} + +function scanTextFromLayout({text, leaves}: DisplayLayout): string { let scanText = ''; let offset = 0; for (const {node, start, end} of leaves) { @@ -428,6 +432,23 @@ function classifyWrap(display: string, start: number, end: number, prefix: strin return 'none'; } +function codeBlockPlanFor( + layout: DisplayLayout, + start: number, + end: number, + prefix: string, + suffix: string, +): CodeBlockWrapPlan | null { + if (prefix !== '`' || suffix !== '`') { + return null; + } + const plan = planCodeBlockWrap(scanTextFromLayout(layout), start, end); + if (plan != null && !plan.wrapped && classifyWrap(layout.text, start, end, prefix, suffix) === 'flank') { + return null; + } + return plan; +} + export function $isComposerSelectionWrapped(prefix: string, suffix: string): boolean { const wrapped = $queryComposerSelectionWrappers([{prefix, suffix}]).wrapped[0]; return wrapped == null ? false : wrapped; @@ -448,7 +469,10 @@ export function $queryComposerSelectionWrappers( } return { offsets, - wrapped: queries.map(({prefix, suffix}) => classifyWrap(layout.text, start, end, prefix, suffix) !== 'none'), + wrapped: queries.map(({prefix, suffix}) => { + const plan = codeBlockPlanFor(layout, start, end, prefix, suffix); + return plan == null ? classifyWrap(layout.text, start, end, prefix, suffix) !== 'none' : plan.wrapped; + }), }; } @@ -469,6 +493,13 @@ export function $wrapComposerSelection(prefix: string, suffix: string): void { const restoreSelection = (nextStart: number, nextEnd: number) => { $selectComposerRange(backward ? nextEnd : nextStart, backward ? nextStart : nextEnd); }; + const plan = codeBlockPlanFor(layout, start, end, prefix, suffix); + if (plan != null) { + replaceTextRange(plan.closing.start, plan.closing.end, plan.closing.text); + replaceTextRange(plan.opening.start, plan.opening.end, plan.opening.text); + restoreSelection(plan.selectionStart, plan.selectionEnd); + return; + } switch (classifyWrap(display, start, end, prefix, suffix)) { case 'inside': { replaceTextRange(end - suffix.length, end, ''); @@ -483,6 +514,9 @@ export function $wrapComposerSelection(prefix: string, suffix: string): void { return; } default: { + if (prefix === '`' && suffix === '`' && selected.includes('\n')) { + return; + } replaceTextRange(end, end, suffix); replaceTextRange(start, start, prefix); selected.length === 0