Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {buildEditorFromExtensions} from '@lexical/extension';
import {
$convertFromMarkdownString,
$convertToMarkdownString,
registerMarkdownShortcuts,
} from '@lexical/markdown';
import {RichTextExtension} from '@lexical/rich-text';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isParagraphNode,
$isRangeSelection,
createEditor,
defineExtension,
LexicalEditor,
} from 'lexical';
import {assert, describe, expect, it} from 'vitest';

import {
$createEquationNode,
$isEquationNode,
EquationNode,
} from '../../src/nodes/EquationNode';
import {BLOCK_EQUATION, EQUATION} from '../../src/plugins/MarkdownTransformers';

const EQUATION_TRANSFORMERS = [BLOCK_EQUATION, EQUATION];
const MarkdownShortcutTestExtension = defineExtension({
dependencies: [RichTextExtension],
name: 'MarkdownShortcutTest',
nodes: [EquationNode],
register: editor => registerMarkdownShortcuts(editor, EQUATION_TRANSFORMERS),
});

function typeMarkdown(editor: LexicalEditor, text: string) {
editor.update(() => {
const selection = $getSelection();
if (!($isRangeSelection(selection) && selection.isCollapsed())) {
$getRoot().selectEnd();
}
});
for (const char of text) {
editor.update(() => $getSelection()?.insertText(char), {discrete: true});
}
editor.read(() => {});
}

describe('playground EQUATION markdown transformer', () => {
it('exports inline equations with single dollar delimiters', () => {
const editor = createEditor({nodes: [EquationNode]});

editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append($createEquationNode('x^2 + y^2 = z^2', true));
$getRoot().append(paragraph);
},
{discrete: true},
);

const markdown = editor
.getEditorState()
.read(() => $convertToMarkdownString(EQUATION_TRANSFORMERS));

expect(markdown).toBe('$x^2 + y^2 = z^2$');
});

it('exports block equations with double dollar delimiters', () => {
const editor = createEditor({nodes: [EquationNode]});

editor.update(
() => {
$getRoot().append($createEquationNode('x^2 + y^2 = z^2', false));
},
{discrete: true},
);

const markdown = editor
.getEditorState()
.read(() => $convertToMarkdownString(EQUATION_TRANSFORMERS));

expect(markdown).toBe('$$\nx^2 + y^2 = z^2\n$$');
});

it('imports multiline double dollar equations as block equations', () => {
const editor = createEditor({nodes: [EquationNode]});

editor.update(
() => {
$convertFromMarkdownString(
'$$\nx^2 + y^2 = z^2\n$$',
EQUATION_TRANSFORMERS,
);
},
{discrete: true},
);

editor.read(() => {
const equation = $getRoot().getFirstChildOrThrow();
assert($isEquationNode(equation), 'Root child must be an EquationNode');
expect(equation.getEquation()).toBe('x^2 + y^2 = z^2');
expect(equation.isInline()).toBe(false);
});
});

it('imports single dollar equations as inline equations', () => {
const editor = createEditor({nodes: [EquationNode]});

editor.update(
() => {
$convertFromMarkdownString('$x^2 + y^2 = z^2$', EQUATION_TRANSFORMERS);
},
{discrete: true},
);

editor.read(() => {
const paragraph = $getRoot().getFirstChildOrThrow();
assert($isParagraphNode(paragraph), 'Root child must be a paragraph');

const equation = paragraph.getFirstChildOrThrow();
assert(
$isEquationNode(equation),
'Paragraph child must be an EquationNode',
);
expect(equation.getEquation()).toBe('x^2 + y^2 = z^2');
expect(equation.isInline()).toBe(true);
});
});

it('imports escaped dollars inside inline equations', () => {
const editor = createEditor({nodes: [EquationNode]});

editor.update(
() => {
$convertFromMarkdownString('$price = \\$5$', EQUATION_TRANSFORMERS);
},
{discrete: true},
);

editor.read(() => {
const paragraph = $getRoot().getFirstChildOrThrow();
assert($isParagraphNode(paragraph), 'Root child must be a paragraph');

const equation = paragraph.getFirstChildOrThrow();
assert(
$isEquationNode(equation),
'Paragraph child must be an EquationNode',
);
expect(equation.getEquation()).toBe('price = $5');
expect(equation.isInline()).toBe(true);
});
});

it('exports inline equations without creating block-equation ambiguity', () => {
const editor = createEditor({nodes: [EquationNode]});

editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append(
$createTextNode('$'),
$createEquationNode('x^2 + y^2 = z^2', true),
$createTextNode('$'),
);
$getRoot().append(paragraph);
},
{discrete: true},
);

const markdown = editor
.getEditorState()
.read(() => $convertToMarkdownString(EQUATION_TRANSFORMERS));

expect(markdown).toBe('$$x^2 + y^2 = z^2$$');

const nextEditor = createEditor({nodes: [EquationNode]});
nextEditor.update(
() => {
$convertFromMarkdownString(markdown, EQUATION_TRANSFORMERS);
},
{discrete: true},
);

nextEditor.read(() => {
const paragraph = $getRoot().getFirstChildOrThrow();
assert($isParagraphNode(paragraph), 'Root child must be a paragraph');
const children = paragraph.getChildren();
expect(children.map(child => child.getTextContent())).toEqual([
'$',
'x^2 + y^2 = z^2',
'$',
]);
assert(
$isEquationNode(children[1]),
'Middle child must be an EquationNode',
);
expect(children[1].isInline()).toBe(true);
});
});

it('exports inline equations containing dollar signs without block-equation ambiguity', () => {
const editor = createEditor({nodes: [EquationNode]});

editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append($createEquationNode('price = $5', true));
$getRoot().append(paragraph);
},
{discrete: true},
);

const markdown = editor
.getEditorState()
.read(() => $convertToMarkdownString(EQUATION_TRANSFORMERS));

expect(markdown).toBe('$price = \\$5$');

const nextEditor = createEditor({nodes: [EquationNode]});
nextEditor.update(
() => {
$convertFromMarkdownString(markdown, EQUATION_TRANSFORMERS);
},
{discrete: true},
);

nextEditor.read(() => {
const paragraph = $getRoot().getFirstChildOrThrow();
assert($isParagraphNode(paragraph), 'Root child must be a paragraph');

const equation = paragraph.getFirstChildOrThrow();
assert(
$isEquationNode(equation),
'Paragraph child must be an EquationNode',
);
expect(equation.getEquation()).toBe('price = $5');
expect(equation.isInline()).toBe(true);
});
});

it('uses a block equation when typing double dollar markdown', () => {
using editor = buildEditorFromExtensions(MarkdownShortcutTestExtension);
typeMarkdown(editor, '$$x^2 + y^2 = z^2$$');

editor.read(() => {
const equation = $getRoot().getFirstChildOrThrow();
assert($isEquationNode(equation), 'Root child must be an EquationNode');
expect(equation.getEquation()).toBe('x^2 + y^2 = z^2');
expect(equation.isInline()).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ELEMENT_TRANSFORMERS,
ElementTransformer,
MULTILINE_ELEMENT_TRANSFORMERS,
MultilineElementTransformer,
TEXT_FORMAT_TRANSFORMERS,
TEXT_MATCH_TRANSFORMERS,
TextMatchTransformer,
Expand Down Expand Up @@ -112,21 +113,69 @@ export const EMOJI: TextMatchTransformer = {
type: 'text-match',
};

function escapeInlineEquation(equation: string): string {
return equation.replace(/([\\$])/g, '\\$1');
}

function unescapeInlineEquation(equation: string): string {
return equation.replace(/\\([\\$])/g, '$1');
}

export const BLOCK_EQUATION: MultilineElementTransformer = {
dependencies: [EquationNode],
export: node => {
if (!$isEquationNode(node) || node.isInline()) {
return null;
}

return `$$\n${node.getEquation()}\n$$`;
},
regExpEnd: /^\$\$\s*$/,
regExpStart: /^\$\$\s*$/,
replace: (rootNode, _children, _startMatch, _endMatch, linesInBetween) => {
const equationLines = linesInBetween ?? [];
if (equationLines[0] === '') {
equationLines.shift();
}
if (equationLines[equationLines.length - 1] === '') {
equationLines.pop();
}
rootNode.append($createEquationNode(equationLines.join('\n'), false));
},
type: 'multiline-element',
};

export const EQUATION: TextMatchTransformer = {
dependencies: [EquationNode],
export: node => {
if (!$isEquationNode(node)) {
return null;
}

return `$${node.getEquation()}$`;
const equation = node.getEquation();
return node.isInline() ? `$${escapeInlineEquation(equation)}$` : null;
},
importRegExp: /\$([^$]+?)\$/,
regExp: /\$([^$]+?)\$$/,
importRegExp: /\$((?:\\.|[^$\\\n])+?)\$/,
regExp: /^\$\$([^$]+?)\$\$$|(?:^|[^$])\$((?:\\.|[^$\\\n])+?)\$$/,
replace: (textNode, match) => {
const [, equation] = match;
const equationNode = $createEquationNode(equation, true);
textNode.replace(equationNode);
const [, firstEquation, secondEquation] = match;
const isInline = !match[0].startsWith('$$');
const equation = firstEquation ?? secondEquation;
const equationNode = isInline
? $createEquationNode(unescapeInlineEquation(equation), true)
: new EquationNode(equation, false);
if (isInline) {
const prefix =
match[0][0] === '$' || match[0][0] === '\\' ? '' : match[0][0];
if (prefix === '') {
textNode.replace(equationNode);
} else {
textNode.setTextContent(prefix);
textNode.insertAfter(equationNode);
}
} else {
textNode.getParentOrThrow().replace(equationNode);
}
},
trigger: '$',
type: 'text-match',
Expand Down Expand Up @@ -314,6 +363,7 @@ export const PLAYGROUND_TRANSFORMERS: Array<Transformer> = [
HR,
IMAGE,
EMOJI,
BLOCK_EQUATION,
EQUATION,
TWEET,
CHECK_LIST,
Expand Down
Loading