,
): TextFormatTransformersIndex {
diff --git a/packages/lexical-markdown/src/MarkdownShortcuts.ts b/packages/lexical-markdown/src/MarkdownShortcuts.ts
index e6b5d7e523c..01f1e808fc6 100644
--- a/packages/lexical-markdown/src/MarkdownShortcuts.ts
+++ b/packages/lexical-markdown/src/MarkdownShortcuts.ts
@@ -28,6 +28,7 @@ import {
import invariant from 'shared/invariant';
import {TRANSFORMERS} from '.';
+import {canContainTransformableMarkdown} from './importTextTransformers';
import {indexBy, PUNCTUATION_OR_SPACE, transformersByType} from './utils';
function runElementTransformers(
@@ -497,7 +498,7 @@ export function registerMarkdownShortcuts(
const anchorNode = editorState._nodeMap.get(anchorKey);
if (
- !$isTextNode(anchorNode) ||
+ !canContainTransformableMarkdown(anchorNode) ||
!dirtyLeaves.has(anchorKey) ||
(anchorOffset !== 1 && anchorOffset > prevSelection.anchor.offset + 1)
) {
@@ -505,11 +506,6 @@ export function registerMarkdownShortcuts(
}
editor.update(() => {
- // Markdown is not available inside code
- if (anchorNode.hasFormat('code')) {
- return;
- }
-
const parentNode = anchorNode.getParent();
if (parentNode === null || $isCodeNode(parentNode)) {
diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts
index 2a4fa5d5cf0..f22067abc0c 100644
--- a/packages/lexical-markdown/src/MarkdownTransformers.ts
+++ b/packages/lexical-markdown/src/MarkdownTransformers.ts
@@ -30,7 +30,6 @@ import {
import {
$createLineBreakNode,
$createTextNode,
- $isTextNode,
ElementNode,
Klass,
LexicalNode,
@@ -172,8 +171,11 @@ export type TextMatchTransformer = Readonly<{
regExp: RegExp;
/**
* Determines how the matched markdown text should be transformed into a node during the markdown import process
+ *
+ * @returns nothing, or a TextNode that may be a child of the new node that is created.
+ * If a TextNode is returned, text format matching will be applied to it (e.g. bold, italic, etc.)
*/
- replace?: (node: TextNode, match: RegExpMatchArray) => void;
+ replace?: (node: TextNode, match: RegExpMatchArray) => void | TextNode;
/**
* For import operations, this function can be used to determine the end index of the match, after `importRegExp` has matched.
* Without this function, the end index will be determined by the length of the match from `importRegExp`. Manually determining the end index can be useful if
@@ -542,17 +544,14 @@ export const LINK: TextMatchTransformer = {
return null;
}
const title = node.getTitle();
+
+ const textContent = exportChildren(node);
+
const linkContent = title
- ? `[${node.getTextContent()}](${node.getURL()} "${title}")`
- : `[${node.getTextContent()}](${node.getURL()})`;
- const firstChild = node.getFirstChild();
- // Add text styles only if link has single text node inside. If it's more
- // then one we ignore it as markdown does not support nested styles for links
- if (node.getChildrenSize() === 1 && $isTextNode(firstChild)) {
- return exportFormat(firstChild, linkContent);
- } else {
- return linkContent;
- }
+ ? `[${textContent}](${node.getURL()} "${title}")`
+ : `[${textContent}](${node.getURL()})`;
+
+ return linkContent;
},
importRegExp:
/(?:\[([^[]+)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))/,
@@ -565,6 +564,8 @@ export const LINK: TextMatchTransformer = {
linkTextNode.setFormat(textNode.getFormat());
linkNode.append(linkTextNode);
textNode.replace(linkNode);
+
+ return linkTextNode;
},
trigger: ')',
type: 'text-match',
diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts
index 0557bd09a10..5ad369ff713 100644
--- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts
+++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts
@@ -222,6 +222,7 @@ describe('Markdown', () => {
shouldPreserveNewLines?: true;
shouldMergeAdjacentLines?: true | false;
customTransformers?: Transformer[];
+ mdAfterExport?: string;
}>;
const URL = 'https://lexical.dev';
@@ -520,6 +521,56 @@ describe('Markdown', () => {
md: 'Hello One Two there',
skipExport: true,
},
+ {
+ html: 'text
',
+ md: '[text](https://lexical.dev)',
+ },
+ {
+ html: 'text
',
+ md: '`text`',
+ },
+ {
+ html: 'text
',
+ md: '[`text`](https://lexical.dev)',
+ },
+ {
+ html: 'Bold text
Bold 2
',
+ md: '**Bold** [`text`](https://lexical.dev) **Bold 2**',
+ },
+ {
+ html: 'Bold text
Bold 2 Bold 3
',
+ md: '**Bold** [`text` **Bold 2**](https://lexical.dev) **Bold 3**',
+ },
+ {
+ html: 'Bold text **Bold in code**
Bold 3
',
+ md: '**Bold** [`text **Bold in code**`](https://lexical.dev) **Bold 3**',
+ },
+ {
+ html: 'Text boldstart text boldend text
',
+ md: 'Text **boldstart [text](https://lexical.dev) boldend** text',
+ },
+ {
+ html: 'Text boldstart text
boldend text
',
+ md: 'Text **boldstart [`text`](https://lexical.dev) boldend** text',
+ },
+ {
+ html: 'It works with links too
',
+ md: 'It ~~___works [with links](https://lexical.io)___~~ too',
+ mdAfterExport: 'It ***~~works [with links](https://lexical.io)~~*** too',
+ },
+ {
+ html: 'It works with links too!
',
+ md: 'It ~~___works [with links](https://lexical.io) too___~~!',
+ mdAfterExport: 'It ***~~works [with links](https://lexical.io) too~~***!',
+ },
+ {
+ html: 'linklink2
',
+ md: '[link](https://lexical.dev)[link2](https://lexical.dev)',
+ },
+ {
+ html: 'Bold Code
',
+ md: '**`Bold Code`**',
+ },
];
const HIGHLIGHT_TEXT_MATCH_IMPORT: TextMatchTransformer = {
@@ -584,6 +635,7 @@ describe('Markdown', () => {
skipExport,
shouldPreserveNewLines,
customTransformers,
+ mdAfterExport,
} of IMPORT_AND_EXPORT) {
if (skipExport) {
continue;
@@ -624,7 +676,7 @@ describe('Markdown', () => {
shouldPreserveNewLines,
),
),
- ).toBe(md);
+ ).toBe(mdAfterExport ?? md);
});
}
});
diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts
new file mode 100644
index 00000000000..7abdd37b63f
--- /dev/null
+++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts
@@ -0,0 +1,137 @@
+/**
+ * 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 type {TextFormatTransformersIndex} from './MarkdownImport';
+import type {TextFormatTransformer} from './MarkdownTransformers';
+import type {TextNode} from 'lexical';
+
+import {PUNCTUATION_OR_SPACE} from './utils';
+
+export function findOutermostTextFormatTransformer(
+ textNode: TextNode,
+ textFormatTransformersIndex: TextFormatTransformersIndex,
+): {
+ startIndex: number;
+ endIndex: number;
+ transformer: TextFormatTransformer;
+ match: RegExpMatchArray;
+} | null {
+ const textContent = textNode.getTextContent();
+ const match = findOutermostMatch(textContent, textFormatTransformersIndex);
+
+ if (!match) {
+ return null;
+ }
+
+ const textFormatMatchStart: number = match.index || 0;
+ const textFormatMatchEnd = textFormatMatchStart + match[0].length;
+
+ const transformer: TextFormatTransformer =
+ textFormatTransformersIndex.transformersByTag[match[1]];
+
+ return {
+ endIndex: textFormatMatchEnd,
+ match,
+ startIndex: textFormatMatchStart,
+ transformer,
+ };
+}
+
+// Finds first "content" match that is not nested into another tag
+function findOutermostMatch(
+ textContent: string,
+ textTransformersIndex: TextFormatTransformersIndex,
+): RegExpMatchArray | null {
+ const openTagsMatch = textContent.match(textTransformersIndex.openTagsRegExp);
+
+ if (openTagsMatch == null) {
+ return null;
+ }
+
+ for (const match of openTagsMatch) {
+ // Open tags reg exp might capture leading space so removing it
+ // before using match to find transformer
+ const tag = match.replace(/^\s/, '');
+ const fullMatchRegExp = textTransformersIndex.fullMatchRegExpByTag[tag];
+ if (fullMatchRegExp == null) {
+ continue;
+ }
+
+ const fullMatch = textContent.match(fullMatchRegExp);
+ const transformer = textTransformersIndex.transformersByTag[tag];
+ if (fullMatch != null && transformer != null) {
+ if (transformer.intraword !== false) {
+ return fullMatch;
+ }
+
+ // For non-intraword transformers checking if it's within a word
+ // or surrounded with space/punctuation/newline
+ const {index = 0} = fullMatch;
+ const beforeChar = textContent[index - 1];
+ const afterChar = textContent[index + fullMatch[0].length];
+
+ if (
+ (!beforeChar || PUNCTUATION_OR_SPACE.test(beforeChar)) &&
+ (!afterChar || PUNCTUATION_OR_SPACE.test(afterChar))
+ ) {
+ return fullMatch;
+ }
+ }
+ }
+
+ return null;
+}
+
+export function importTextFormatTransformer(
+ textNode: TextNode,
+ startIndex: number,
+ endIndex: number,
+ transformer: TextFormatTransformer,
+ match: RegExpMatchArray,
+): {
+ transformedNode: TextNode;
+ nodeBefore: TextNode | undefined; // If split
+ nodeAfter: TextNode | undefined; // If split
+} {
+ const textContent = textNode.getTextContent();
+
+ // No text matches - we can safely process the text format match
+ let transformedNode, nodeAfter, nodeBefore;
+
+ // If matching full content there's no need to run splitText and can reuse existing textNode
+ // to update its content and apply format. E.g. for **_Hello_** string after applying bold
+ // format (**) it will reuse the same text node to apply italic (_)
+ if (match[0] === textContent) {
+ transformedNode = textNode;
+ } else {
+ if (startIndex === 0) {
+ [transformedNode, nodeAfter] = textNode.splitText(endIndex);
+ } else {
+ [nodeBefore, transformedNode, nodeAfter] = textNode.splitText(
+ startIndex,
+ endIndex,
+ );
+ }
+ }
+
+ transformedNode.setTextContent(match[2]);
+
+ if (transformer) {
+ for (const format of transformer.format) {
+ if (!transformedNode.hasFormat(format)) {
+ transformedNode.toggleFormat(format);
+ }
+ }
+ }
+
+ return {
+ nodeAfter: nodeAfter,
+ nodeBefore: nodeBefore,
+ transformedNode: transformedNode,
+ };
+}
diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts
new file mode 100644
index 00000000000..91651bd9338
--- /dev/null
+++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts
@@ -0,0 +1,108 @@
+/**
+ * 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 type {TextMatchTransformer} from './MarkdownTransformers';
+
+import {type TextNode} from 'lexical';
+
+export function findOutermostTextMatchTransformer(
+ textNode_: TextNode,
+ textMatchTransformers: Array,
+): {
+ startIndex: number;
+ endIndex: number;
+ transformer: TextMatchTransformer;
+ match: RegExpMatchArray;
+} | null {
+ const textNode = textNode_;
+
+ let foundMatchStartIndex: number | undefined = undefined;
+ let foundMatchEndIndex: number | undefined = undefined;
+ let foundMatchTransformer: TextMatchTransformer | undefined = undefined;
+ let foundMatch: RegExpMatchArray | undefined = undefined;
+
+ for (const transformer of textMatchTransformers) {
+ if (!transformer.replace || !transformer.importRegExp) {
+ continue;
+ }
+ const match = textNode.getTextContent().match(transformer.importRegExp);
+
+ if (!match) {
+ continue;
+ }
+
+ const startIndex = match.index || 0;
+ const endIndex = transformer.getEndIndex
+ ? transformer.getEndIndex(textNode, match)
+ : startIndex + match[0].length;
+
+ if (endIndex === false) {
+ continue;
+ }
+
+ if (
+ foundMatchStartIndex === undefined ||
+ foundMatchEndIndex === undefined ||
+ (startIndex < foundMatchStartIndex && endIndex > foundMatchEndIndex)
+ ) {
+ foundMatchStartIndex = startIndex;
+ foundMatchEndIndex = endIndex;
+ foundMatchTransformer = transformer;
+ foundMatch = match;
+ }
+ }
+
+ if (
+ foundMatchStartIndex === undefined ||
+ foundMatchEndIndex === undefined ||
+ foundMatchTransformer === undefined ||
+ foundMatch === undefined
+ ) {
+ return null;
+ }
+
+ return {
+ endIndex: foundMatchEndIndex,
+ match: foundMatch,
+ startIndex: foundMatchStartIndex,
+ transformer: foundMatchTransformer,
+ };
+}
+
+export function importFoundTextMatchTransformer(
+ textNode: TextNode,
+ startIndex: number,
+ endIndex: number,
+ transformer: TextMatchTransformer,
+ match: RegExpMatchArray,
+): {
+ transformedNode?: TextNode;
+ nodeBefore: TextNode | undefined; // If split
+ nodeAfter: TextNode | undefined; // If split
+} | null {
+ let transformedNode, nodeAfter, nodeBefore;
+
+ if (startIndex === 0) {
+ [transformedNode, nodeAfter] = textNode.splitText(endIndex);
+ } else {
+ [nodeBefore, transformedNode, nodeAfter] = textNode.splitText(
+ startIndex,
+ endIndex,
+ );
+ }
+
+ if (!transformer.replace) {
+ return null;
+ }
+ const potentialTransformedNode = transformer.replace(transformedNode, match);
+
+ return {
+ nodeAfter,
+ nodeBefore,
+ transformedNode: potentialTransformedNode || undefined,
+ };
+}
diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts
new file mode 100644
index 00000000000..e75c4333d62
--- /dev/null
+++ b/packages/lexical-markdown/src/importTextTransformers.ts
@@ -0,0 +1,138 @@
+/**
+ * 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 type {TextFormatTransformersIndex} from './MarkdownImport';
+import type {TextMatchTransformer} from './MarkdownTransformers';
+
+import {$isTextNode, type LexicalNode, type TextNode} from 'lexical';
+
+import {
+ findOutermostTextFormatTransformer,
+ importTextFormatTransformer,
+} from './importTextFormatTransformer';
+import {
+ findOutermostTextMatchTransformer,
+ importFoundTextMatchTransformer,
+} from './importTextMatchTransformer';
+
+/**
+ * Returns true if the node can contain transformable markdown.
+ * Code nodes cannot contain transformable markdown.
+ * For example, `code **bold**` should not be transformed to
+ * code bold
.
+ */
+export function canContainTransformableMarkdown(
+ node: LexicalNode | undefined,
+): node is TextNode {
+ return $isTextNode(node) && !node.hasFormat('code');
+}
+
+/**
+ * Handles applying both text format and text match transformers.
+ * It finds the outermost text format or text match and applies it,
+ * then recursively calls itself to apply the next outermost transformer,
+ * until there are no more transformers to apply.
+ */
+export function importTextTransformers(
+ textNode: TextNode,
+ textFormatTransformersIndex: TextFormatTransformersIndex,
+ textMatchTransformers: Array,
+) {
+ let foundTextFormat = findOutermostTextFormatTransformer(
+ textNode,
+ textFormatTransformersIndex,
+ );
+
+ let foundTextMatch = findOutermostTextMatchTransformer(
+ textNode,
+ textMatchTransformers,
+ );
+
+ if (foundTextFormat && foundTextMatch) {
+ // Find the outermost transformer
+ if (
+ foundTextFormat.startIndex <= foundTextMatch.startIndex &&
+ foundTextFormat.endIndex >= foundTextMatch.endIndex
+ ) {
+ // foundTextFormat wraps foundTextMatch - apply foundTextFormat by setting foundTextMatch to null
+ foundTextMatch = null;
+ } else {
+ // foundTextMatch wraps foundTextFormat - apply foundTextMatch by setting foundTextFormat to null
+ foundTextFormat = null;
+ }
+ }
+
+ if (foundTextFormat) {
+ const result = importTextFormatTransformer(
+ textNode,
+ foundTextFormat.startIndex,
+ foundTextFormat.endIndex,
+ foundTextFormat.transformer,
+ foundTextFormat.match,
+ );
+
+ if (canContainTransformableMarkdown(result.nodeAfter)) {
+ importTextTransformers(
+ result.nodeAfter,
+ textFormatTransformersIndex,
+ textMatchTransformers,
+ );
+ }
+ if (canContainTransformableMarkdown(result.nodeBefore)) {
+ importTextTransformers(
+ result.nodeBefore,
+ textFormatTransformersIndex,
+ textMatchTransformers,
+ );
+ }
+ if (canContainTransformableMarkdown(result.transformedNode)) {
+ importTextTransformers(
+ result.transformedNode,
+ textFormatTransformersIndex,
+ textMatchTransformers,
+ );
+ }
+ return;
+ } else if (foundTextMatch) {
+ const result = importFoundTextMatchTransformer(
+ textNode,
+ foundTextMatch.startIndex,
+ foundTextMatch.endIndex,
+ foundTextMatch.transformer,
+ foundTextMatch.match,
+ );
+ if (!result) {
+ return;
+ }
+
+ if (canContainTransformableMarkdown(result.nodeAfter)) {
+ importTextTransformers(
+ result.nodeAfter,
+ textFormatTransformersIndex,
+ textMatchTransformers,
+ );
+ }
+ if (canContainTransformableMarkdown(result.nodeBefore)) {
+ importTextTransformers(
+ result.nodeBefore,
+ textFormatTransformersIndex,
+ textMatchTransformers,
+ );
+ }
+ if (canContainTransformableMarkdown(result.transformedNode)) {
+ importTextTransformers(
+ result.transformedNode,
+ textFormatTransformersIndex,
+ textMatchTransformers,
+ );
+ }
+ return;
+ } else {
+ // Done!
+ return;
+ }
+}