mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(app): wrap multiline selections in code blocks (#2675)
This commit is contained in:
@@ -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, '') !== '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PlanRow> = [
|
||||
['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<NodeKey> {
|
||||
return editor.read(() =>
|
||||
$getRoot()
|
||||
.getFirstChildOrThrow<ElementNode>()
|
||||
.getChildren()
|
||||
.filter((node) => $isComposerMentionNode(node) || $isComposerCustomEmojiNode(node))
|
||||
.map((node) => node.getKey()),
|
||||
);
|
||||
}
|
||||
|
||||
function atomicState(editor: LexicalEditor, keys: Array<NodeKey>): 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<string> {
|
||||
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<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 parsedCodeBlocks(text: string): Array<string> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<CodeBlockBody>, 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<CodeBlockBody>): Array<FencedBlock> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user