diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index e26ce19db33..ea6d3ef700d 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -38,9 +38,16 @@ export function createMarkdownExport( // Export only uses text formats that are responsible for single format // e.g. it will filter out *** (bold, italic) and instead use separate ** and * - const textFormatTransformers = byType.textFormat.filter( - (transformer) => transformer.format.length === 1, - ); + const textFormatTransformers = byType.textFormat + .filter((transformer) => transformer.format.length === 1) + // Make sure all text transformers that contain 'code' in their format are at the end of the array. Otherwise, formatted code like + // code will be exported as `**Bold Code**`, as the code format will be applied first, and the bold format + // will be applied second and thus skipped entirely, as the code format will prevent any further formatting. + .sort((a, b) => { + return ( + Number(a.format.includes('code')) - Number(b.format.includes('code')) + ); + }); return (node) => { const output = []; @@ -105,11 +112,18 @@ function exportChildren( node: ElementNode, textTransformersIndex: Array, textMatchTransformers: Array, + unclosedTags?: Array<{format: TextFormatType; tag: string}>, + unclosableTags?: Array<{format: TextFormatType; tag: string}>, ): string { const output = []; const children = node.getChildren(); // keep track of unclosed tags from the very beginning - const unclosedTags: {format: TextFormatType; tag: string}[] = []; + if (!unclosedTags) { + unclosedTags = []; + } + if (!unclosableTags) { + unclosableTags = []; + } mainLoop: for (const child of children) { for (const transformer of textMatchTransformers) { @@ -124,6 +138,13 @@ function exportChildren( parentNode, textTransformersIndex, textMatchTransformers, + unclosedTags, + // Add current unclosed tags to the list of unclosable tags - we don't want nested tags from + // textmatch transformers to close the outer ones, as that may result in invalid markdown. + // E.g. **text [text**](https://lexical.io) + // is invalid markdown, as the closing ** is inside the link. + // + [...unclosableTags, ...unclosedTags], ), (textNode, textContent) => exportTextFormat( @@ -131,6 +152,7 @@ function exportChildren( textContent, textTransformersIndex, unclosedTags, + unclosableTags, ), ); @@ -149,12 +171,19 @@ function exportChildren( child.getTextContent(), textTransformersIndex, unclosedTags, + unclosableTags, ), ); } else if ($isElementNode(child)) { // empty paragraph returns "" output.push( - exportChildren(child, textTransformersIndex, textMatchTransformers), + exportChildren( + child, + textTransformersIndex, + textMatchTransformers, + unclosedTags, + unclosableTags, + ), ); } else if ($isDecoratorNode(child)) { output.push(child.getTextContent()); @@ -170,6 +199,7 @@ function exportTextFormat( textTransformers: Array, // unclosed tags include the markdown tags that haven't been closed yet, and their associated formats unclosedTags: Array<{format: TextFormatType; tag: string}>, + unclosableTags?: Array<{format: TextFormatType; tag: string}>, ): string { // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" @@ -180,7 +210,8 @@ function exportTextFormat( // the opening tags to be added to the result let openingTags = ''; // the closing tags to be added to the result - let closingTags = ''; + let closingTagsBefore = ''; + let closingTagsAfter = ''; const prevNode = getTextSibling(node, true); const nextNode = getTextSibling(node, false); @@ -210,23 +241,47 @@ function exportTextFormat( // close any tags in the same order they were applied, if necessary for (let i = 0; i < unclosedTags.length; i++) { + const nodeHasFormat = hasFormat(node, unclosedTags[i].format); + const nextNodeHasFormat = hasFormat(nextNode, unclosedTags[i].format); + // prevent adding closing tag if next sibling will do it - if (hasFormat(nextNode, unclosedTags[i].format)) { + if (nodeHasFormat && nextNodeHasFormat) { continue; } - while (unclosedTags.length > i) { - const unclosedTag = unclosedTags.pop(); + const unhandledUnclosedTags = [...unclosedTags]; // Shallow copy to avoid modifying the original array + + while (unhandledUnclosedTags.length > i) { + const unclosedTag = unhandledUnclosedTags.pop(); + + // If tag is unclosable, don't close it and leave it in the original array, + // So that it can be closed when it's no longer unclosable + if ( + unclosableTags && + unclosedTag && + unclosableTags.find((element) => element.tag === unclosedTag.tag) + ) { + continue; + } + if (unclosedTag && typeof unclosedTag.tag === 'string') { - closingTags += unclosedTag.tag; + if (!nodeHasFormat) { + // Handles cases where the tag has not been closed before, e.g. if the previous node + // was a text match transformer that did not account for closing tags of the next node (e.g. a link) + closingTagsBefore += unclosedTag.tag; + } else if (!nextNodeHasFormat) { + closingTagsAfter += unclosedTag.tag; + } } + // Mutate the original array to remove the closed tag + unclosedTags.pop(); } break; } - output = openingTags + output + closingTags; + output = openingTags + output + closingTagsAfter; // Replace trimmed version of textContent ensuring surrounding whitespace is not modified - return textContent.replace(frozenString, () => output); + return closingTagsBefore + textContent.replace(frozenString, () => output); } // Get next or previous text sibling a text node, including cases diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index 47f56dd3b7e..6d3bd913d45 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -13,7 +13,6 @@ import type { TextMatchTransformer, Transformer, } from './MarkdownTransformers'; -import type {TextNode} from 'lexical'; import {$isListItemNode, $isListNode, ListItemNode} from '@lexical/list'; import {$isQuoteNode} from '@lexical/rich-text'; @@ -29,13 +28,10 @@ import { } from 'lexical'; import {IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from 'shared/environment'; -import { - isEmptyParagraph, - PUNCTUATION_OR_SPACE, - transformersByType, -} from './utils'; +import {importTextTransformers} from './importTextTransformers'; +import {isEmptyParagraph, transformersByType} from './utils'; -type TextFormatTransformersIndex = Readonly<{ +export type TextFormatTransformersIndex = Readonly<{ fullMatchRegExpByTag: Readonly>; openTagsRegExp: RegExp; transformersByTag: Readonly>; @@ -246,7 +242,7 @@ function $importBlocks( } } - importTextFormatTransformers( + importTextTransformers( textNode, textFormatTransformersIndex, textMatchTransformers, @@ -284,177 +280,6 @@ function $importBlocks( } } -// Processing text content and replaces text format tags. -// It takes outermost tag match and its content, creates text node with -// format based on tag and then recursively executed over node's content -// -// E.g. for "*Hello **world**!*" string it will create text node with -// "Hello **world**!" content and italic format and run recursively over -// its content to transform "**world**" part -function importTextFormatTransformers( - textNode: TextNode, - textFormatTransformersIndex: TextFormatTransformersIndex, - textMatchTransformers: Array, -) { - const textContent = textNode.getTextContent(); - const match = findOutermostMatch(textContent, textFormatTransformersIndex); - - if (!match) { - // Once text format processing is done run text match transformers, as it - // only can span within single text node (unline formats that can cover multiple nodes) - importTextMatchTransformers(textNode, textMatchTransformers); - return; - } - - let currentNode, remainderNode, leadingNode; - - // 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) { - currentNode = textNode; - } else { - const startIndex = match.index || 0; - const endIndex = startIndex + match[0].length; - - if (startIndex === 0) { - [currentNode, remainderNode] = textNode.splitText(endIndex); - } else { - [leadingNode, currentNode, remainderNode] = textNode.splitText( - startIndex, - endIndex, - ); - } - } - - currentNode.setTextContent(match[2]); - const transformer = textFormatTransformersIndex.transformersByTag[match[1]]; - - if (transformer) { - for (const format of transformer.format) { - if (!currentNode.hasFormat(format)) { - currentNode.toggleFormat(format); - } - } - } - - // Recursively run over inner text if it's not inline code - if (!currentNode.hasFormat('code')) { - importTextFormatTransformers( - currentNode, - textFormatTransformersIndex, - textMatchTransformers, - ); - } - - // Run over leading/remaining text if any - if (leadingNode) { - importTextFormatTransformers( - leadingNode, - textFormatTransformersIndex, - textMatchTransformers, - ); - } - - if (remainderNode) { - importTextFormatTransformers( - remainderNode, - textFormatTransformersIndex, - textMatchTransformers, - ); - } -} - -function importTextMatchTransformers( - textNode_: TextNode, - textMatchTransformers: Array, -) { - let textNode = textNode_; - - mainLoop: while (textNode) { - 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; - } - - let replaceNode, newTextNode; - - if (startIndex === 0) { - [replaceNode, textNode] = textNode.splitText(endIndex); - } else { - [, replaceNode, newTextNode] = textNode.splitText(startIndex, endIndex); - } - - if (newTextNode) { - importTextMatchTransformers(newTextNode, textMatchTransformers); - } - transformer.replace(replaceNode, match); - continue mainLoop; - } - - break; - } -} - -// 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; -} - function createTextFormatTransformersIndex( textTransformers: Array, ): 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; + } +}