From 01ecf0e0061d1d6776b154e32601d8a384a39c16 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 29 Dec 2024 19:46:07 -0700 Subject: [PATCH 01/20] fix(markdown): incorrect markdown import for links containing text formats --- .../lexical-markdown/src/MarkdownImport.ts | 32 +++++++++++++++---- .../src/MarkdownTransformers.ts | 9 +++++- .../__tests__/unit/LexicalMarkdown.test.ts | 18 +++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index 47f56dd3b7e..1c7020cf2d5 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -25,6 +25,7 @@ import { $getRoot, $getSelection, $isParagraphNode, + $isTextNode, ElementNode, } from 'lexical'; import {IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from 'shared/environment'; @@ -296,13 +297,16 @@ function importTextFormatTransformers( textFormatTransformersIndex: TextFormatTransformersIndex, textMatchTransformers: Array, ) { + importTextMatchTransformers( + textNode, + textFormatTransformersIndex, + textMatchTransformers, + ); + 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; } @@ -367,8 +371,9 @@ function importTextFormatTransformers( function importTextMatchTransformers( textNode_: TextNode, + textFormatTransformersIndex: TextFormatTransformersIndex, textMatchTransformers: Array, -) { +): void { let textNode = textNode_; mainLoop: while (textNode) { @@ -400,14 +405,29 @@ function importTextMatchTransformers( } if (newTextNode) { - importTextMatchTransformers(newTextNode, textMatchTransformers); + importTextFormatTransformers( + newTextNode, + textFormatTransformersIndex, + textMatchTransformers, + ); + } + const potentialTextNode: any = transformer.replace(replaceNode, match); + + // If a TextNode is returned from the replace function, we need to run the text format transformers on it. + // This is used in the Link transformer, where the link text is a separate TextNode that needs to be processed. + if (potentialTextNode && $isTextNode(potentialTextNode)) { + importTextFormatTransformers( + potentialTextNode, + textFormatTransformersIndex, + textMatchTransformers, + ); } - transformer.replace(replaceNode, match); continue mainLoop; } break; } + return; } // Finds first "content" match that is not nested into another tag diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index 2a4fa5d5cf0..05d3a8f2746 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -172,8 +172,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 @@ -537,6 +540,8 @@ export const ITALIC_UNDERSCORE: TextFormatTransformer = { // - then longer tags match (e.g. ** or __ should go before * or _) export const LINK: TextMatchTransformer = { dependencies: [LinkNode], + // @ts-expect-error + debug: true, export: (node, exportChildren, exportFormat) => { if (!$isLinkNode(node)) { return null; @@ -565,6 +570,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..0ff04fea721 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -520,6 +520,24 @@ describe('Markdown', () => { md: 'Hello One Two there', skipExport: true, }, + { + html: '

lang

', + md: '[lang](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang)', + }, + { + html: '

lang

', + md: '`lang`', + }, + { + html: '

lang

', + md: '[`lang`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang)', + skipExport: true, + }, + { + html: '

Bold lang Bold 2

', + md: '**Bold** [`lang`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang) **Bold 2**', + skipExport: true, + }, ]; const HIGHLIGHT_TEXT_MATCH_IMPORT: TextMatchTransformer = { From 3628b824ee11c7be52aa324d689d6c3c61625681 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 29 Dec 2024 19:50:00 -0700 Subject: [PATCH 02/20] simpler test examples --- .../src/__tests__/unit/LexicalMarkdown.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 0ff04fea721..67a27c28900 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -521,21 +521,21 @@ describe('Markdown', () => { skipExport: true, }, { - html: '

lang

', - md: '[lang](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang)', + html: '

text

', + md: '[text](https://lexical.dev)', }, { - html: '

lang

', - md: '`lang`', + html: '

text

', + md: '`text`', }, { - html: '

lang

', - md: '[`lang`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang)', + html: '

text

', + md: '[`text`](https://lexical.dev)', skipExport: true, }, { - html: '

Bold lang Bold 2

', - md: '**Bold** [`lang`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang) **Bold 2**', + html: '

Bold text Bold 2

', + md: '**Bold** [`text`](https://lexical.dev) **Bold 2**', skipExport: true, }, ]; From eb0ca8451a64d7e87936d27b6d448e95ca1a9411 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sun, 29 Dec 2024 20:00:54 -0700 Subject: [PATCH 03/20] fix: markdown export for link nodes with text formats --- .../src/MarkdownTransformers.ts | 19 +++++++------------ .../__tests__/unit/LexicalMarkdown.test.ts | 10 ++++++++-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index 05d3a8f2746..60e048be6f9 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -540,24 +540,19 @@ export const ITALIC_UNDERSCORE: TextFormatTransformer = { // - then longer tags match (e.g. ** or __ should go before * or _) export const LINK: TextMatchTransformer = { dependencies: [LinkNode], - // @ts-expect-error - debug: true, export: (node, exportChildren, exportFormat) => { if (!$isLinkNode(node)) { 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*)?)\))/, diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 67a27c28900..fb8876e9781 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -531,12 +531,18 @@ describe('Markdown', () => { { html: '

text

', md: '[`text`](https://lexical.dev)', - skipExport: true, }, { html: '

Bold text Bold 2

', md: '**Bold** [`text`](https://lexical.dev) **Bold 2**', - skipExport: true, + }, + { + 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**', }, ]; From 957bc660aff3339811b903c7b27294681c52e36b Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 10:45:26 -0700 Subject: [PATCH 04/20] new text format & text match importing logic. This ensures that outermost matching applies to both text format and text match transformers, instead of just text format transformers --- .../lexical-markdown/src/MarkdownImport.ts | 203 +----------------- .../src/importTextFormatTransformer.ts | 136 ++++++++++++ .../src/importTextMatchTransformer.ts | 103 +++++++++ .../src/importTextTransformers.ts | 137 ++++++++++++ 4 files changed, 380 insertions(+), 199 deletions(-) create mode 100644 packages/lexical-markdown/src/importTextFormatTransformer.ts create mode 100644 packages/lexical-markdown/src/importTextMatchTransformer.ts create mode 100644 packages/lexical-markdown/src/importTextTransformers.ts diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index 1c7020cf2d5..4e808397f0b 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'; @@ -25,18 +24,14 @@ import { $getRoot, $getSelection, $isParagraphNode, - $isTextNode, ElementNode, } from 'lexical'; import {IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from 'shared/environment'; -import { - isEmptyParagraph, - PUNCTUATION_OR_SPACE, - transformersByType, -} from './utils'; +import {isEmptyParagraph, transformersByType} from './utils'; +import {importTextTransformers} from './importTextTransformers'; -type TextFormatTransformersIndex = Readonly<{ +export type TextFormatTransformersIndex = Readonly<{ fullMatchRegExpByTag: Readonly>; openTagsRegExp: RegExp; transformersByTag: Readonly>; @@ -247,7 +242,7 @@ function $importBlocks( } } - importTextFormatTransformers( + importTextTransformers( textNode, textFormatTransformersIndex, textMatchTransformers, @@ -285,196 +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, -) { - importTextMatchTransformers( - textNode, - textFormatTransformersIndex, - textMatchTransformers, - ); - - const textContent = textNode.getTextContent(); - const match = findOutermostMatch(textContent, textFormatTransformersIndex); - - if (!match) { - 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, - textFormatTransformersIndex: TextFormatTransformersIndex, - textMatchTransformers: Array, -): void { - 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) { - importTextFormatTransformers( - newTextNode, - textFormatTransformersIndex, - textMatchTransformers, - ); - } - const potentialTextNode: any = transformer.replace(replaceNode, match); - - // If a TextNode is returned from the replace function, we need to run the text format transformers on it. - // This is used in the Link transformer, where the link text is a separate TextNode that needs to be processed. - if (potentialTextNode && $isTextNode(potentialTextNode)) { - importTextFormatTransformers( - potentialTextNode, - textFormatTransformersIndex, - textMatchTransformers, - ); - } - continue mainLoop; - } - - break; - } - return; -} - -// 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/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts new file mode 100644 index 00000000000..67d1a02dcaa --- /dev/null +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -0,0 +1,136 @@ +// 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 + +import type {TextNode} from 'lexical'; +import type {TextFormatTransformersIndex} from './MarkdownImport'; +import type {TextFormatTransformer} from './MarkdownTransformers'; +import {PUNCTUATION_OR_SPACE} from './utils'; + +// its content to transform "**world**" part +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 { + startIndex: textFormatMatchStart, + endIndex: textFormatMatchEnd, + transformer, + match, + }; +} + +// 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 { + transformedNode: transformedNode, + nodeBefore: nodeBefore, + nodeAfter: nodeAfter, + }; +} diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts new file mode 100644 index 00000000000..ba1a1cd7018 --- /dev/null +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -0,0 +1,103 @@ +import {type TextNode} from 'lexical'; +import type {TextMatchTransformer} from './MarkdownTransformers'; + +export function findOutermostTextMatchTransformer( + textNode_: TextNode, + textMatchTransformers: Array, +): { + startIndex: number; + endIndex: number; + transformer: TextMatchTransformer; + match: RegExpMatchArray; +} | null { + let 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 { + startIndex: foundMatchStartIndex, + endIndex: foundMatchEndIndex, + transformer: foundMatchTransformer, + match: foundMatch, + }; +} + +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, textNode] = textNode.splitText(endIndex); + } else { + [nodeBefore, transformedNode, nodeAfter] = textNode.splitText( + startIndex, + endIndex, + ); + } + + if (!transformer?.replace) { + return null; + } + const potentialTransformedNode: any = transformer.replace( + transformedNode, + match, + ); + + return { + nodeAfter, + nodeBefore, + transformedNode: potentialTransformedNode, + }; +} diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts new file mode 100644 index 00000000000..0c3b0d2560a --- /dev/null +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -0,0 +1,137 @@ +import {$isTextNode, type TextNode} from 'lexical'; +import type {TextFormatTransformersIndex} from './MarkdownImport'; +import type {TextMatchTransformer} from './MarkdownTransformers'; +import { + findOutermostTextFormatTransformer, + importTextFormatTransformer, +} from './importTextFormatTransformer'; +import { + findOutermostTextMatchTransformer, + importFoundTextMatchTransformer, +} from './importTextMatchTransformer'; + +/** + * 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 ( + result?.nodeAfter && + $isTextNode(result.nodeAfter) && + !result.nodeAfter.hasFormat('code') + ) { + importTextTransformers( + result.nodeAfter, + textFormatTransformersIndex, + textMatchTransformers, + ); + } + if ( + result?.nodeBefore && + $isTextNode(result.nodeBefore) && + !result.nodeBefore.hasFormat('code') + ) { + importTextTransformers( + result.nodeBefore, + textFormatTransformersIndex, + textMatchTransformers, + ); + } + if ( + result?.transformedNode && + $isTextNode(result.transformedNode) && + !result.transformedNode.hasFormat('code') + ) { + importTextTransformers( + result.transformedNode, + textFormatTransformersIndex, + textMatchTransformers, + ); + } + return; + } else if (foundTextMatch) { + const result = importFoundTextMatchTransformer( + textNode, + foundTextMatch.startIndex, + foundTextMatch.endIndex, + foundTextMatch.transformer, + foundTextMatch.match, + ); + if ( + result?.nodeAfter && + $isTextNode(result.nodeAfter) && + !result.nodeAfter.hasFormat('code') + ) { + importTextTransformers( + result.nodeAfter, + textFormatTransformersIndex, + textMatchTransformers, + ); + } + if ( + result?.nodeBefore && + $isTextNode(result.nodeBefore) && + !result.nodeBefore.hasFormat('code') + ) { + importTextTransformers( + result.nodeBefore, + textFormatTransformersIndex, + textMatchTransformers, + ); + } + if ( + result?.transformedNode && + $isTextNode(result.transformedNode) && + !result.transformedNode.hasFormat('code') + ) { + importTextTransformers( + result.transformedNode, + textFormatTransformersIndex, + textMatchTransformers, + ); + } + return; + } else { + // Done! + return; + } +} From 054c86547d6c9c65316eb55ee7d3f527e1e9981f Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 10:45:30 -0700 Subject: [PATCH 05/20] add tests --- .../src/__tests__/unit/LexicalMarkdown.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index fb8876e9781..94e45705fc0 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -544,6 +544,16 @@ describe('Markdown', () => { 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', + skipExport: true, + }, + { + html: '

Text boldstart text boldend text

', + md: 'Text **boldstart [`text`](https://lexical.dev) boldend** text', + skipExport: true, + }, ]; const HIGHLIGHT_TEXT_MATCH_IMPORT: TextMatchTransformer = { From 320a5691b173b584a099142818f026502b54f1e2 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 11:05:21 -0700 Subject: [PATCH 06/20] fix export --- packages/lexical-markdown/src/MarkdownExport.ts | 14 ++++++++++++-- .../src/__tests__/unit/LexicalMarkdown.test.ts | 2 -- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index e26ce19db33..70ef7a555eb 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -105,11 +105,14 @@ function exportChildren( node: ElementNode, textTransformersIndex: Array, textMatchTransformers: Array, + unclosedTags?: 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 = []; + } mainLoop: for (const child of children) { for (const transformer of textMatchTransformers) { @@ -124,6 +127,7 @@ function exportChildren( parentNode, textTransformersIndex, textMatchTransformers, + unclosedTags, ), (textNode, textContent) => exportTextFormat( @@ -154,7 +158,12 @@ function exportChildren( } else if ($isElementNode(child)) { // empty paragraph returns "" output.push( - exportChildren(child, textTransformersIndex, textMatchTransformers), + exportChildren( + child, + textTransformersIndex, + textMatchTransformers, + unclosedTags, + ), ); } else if ($isDecoratorNode(child)) { output.push(child.getTextContent()); @@ -171,6 +180,7 @@ function exportTextFormat( // unclosed tags include the markdown tags that haven't been closed yet, and their associated formats unclosedTags: Array<{format: TextFormatType; tag: string}>, ): string { + console.log('exportTextFormat', {node, textContent}); // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" // We instead want to trim the whitespace out, apply formatting, and then diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 94e45705fc0..dfdfc8e3339 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -547,12 +547,10 @@ describe('Markdown', () => { { html: '

Text boldstart text boldend text

', md: 'Text **boldstart [text](https://lexical.dev) boldend** text', - skipExport: true, }, { html: '

Text boldstart text boldend text

', md: 'Text **boldstart [`text`](https://lexical.dev) boldend** text', - skipExport: true, }, ]; From 5c4e11b19c67481da955d8816b30067abe8be760 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 11:08:18 -0700 Subject: [PATCH 07/20] remove console.log --- packages/lexical-markdown/src/MarkdownExport.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 70ef7a555eb..cbd60bdf8cb 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -180,7 +180,6 @@ function exportTextFormat( // unclosed tags include the markdown tags that haven't been closed yet, and their associated formats unclosedTags: Array<{format: TextFormatType; tag: string}>, ): string { - console.log('exportTextFormat', {node, textContent}); // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" // We instead want to trim the whitespace out, apply formatting, and then From 14cda528bd3d98229a16fe8daed040a95385576e Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 11:37:00 -0700 Subject: [PATCH 08/20] fix lint errors --- .../lexical-markdown/src/MarkdownImport.ts | 2 +- .../src/MarkdownTransformers.ts | 1 - .../src/importTextFormatTransformer.ts | 25 ++++++++--------- .../src/importTextMatchTransformer.ts | 27 +++++++++++-------- .../src/importTextTransformers.ts | 27 ++++++++++++++----- 5 files changed, 50 insertions(+), 32 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index 4e808397f0b..6d3bd913d45 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -28,8 +28,8 @@ import { } from 'lexical'; import {IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from 'shared/environment'; -import {isEmptyParagraph, transformersByType} from './utils'; import {importTextTransformers} from './importTextTransformers'; +import {isEmptyParagraph, transformersByType} from './utils'; export type TextFormatTransformersIndex = Readonly<{ fullMatchRegExpByTag: Readonly>; diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index 60e048be6f9..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, diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 67d1a02dcaa..ca725266f15 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -1,16 +1,17 @@ -// 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 +/** + * 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 {TextNode} from 'lexical'; import type {TextFormatTransformersIndex} from './MarkdownImport'; import type {TextFormatTransformer} from './MarkdownTransformers'; +import type {TextNode} from 'lexical'; + import {PUNCTUATION_OR_SPACE} from './utils'; -// its content to transform "**world**" part export function findOutermostTextFormatTransformer( textNode: TextNode, textFormatTransformersIndex: TextFormatTransformersIndex, @@ -34,10 +35,10 @@ export function findOutermostTextFormatTransformer( textFormatTransformersIndex.transformersByTag[match[1]]; return { - startIndex: textFormatMatchStart, endIndex: textFormatMatchEnd, - transformer, match, + startIndex: textFormatMatchStart, + transformer, }; } @@ -129,8 +130,8 @@ export function importTextFormatTransformer( } return { - transformedNode: transformedNode, - nodeBefore: nodeBefore, nodeAfter: nodeAfter, + nodeBefore: nodeBefore, + transformedNode: transformedNode, }; } diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index ba1a1cd7018..48086661779 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -1,6 +1,14 @@ -import {type TextNode} from 'lexical'; +/** + * 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, @@ -10,7 +18,7 @@ export function findOutermostTextMatchTransformer( transformer: TextMatchTransformer; match: RegExpMatchArray; } | null { - let textNode = textNode_; + const textNode = textNode_; let foundMatchStartIndex: number | undefined = undefined; let foundMatchEndIndex: number | undefined = undefined; @@ -58,10 +66,10 @@ export function findOutermostTextMatchTransformer( } return { - startIndex: foundMatchStartIndex, endIndex: foundMatchEndIndex, - transformer: foundMatchTransformer, match: foundMatch, + startIndex: foundMatchStartIndex, + transformer: foundMatchTransformer, }; } @@ -72,7 +80,7 @@ export function importFoundTextMatchTransformer( transformer: TextMatchTransformer, match: RegExpMatchArray, ): { - transformedNode: TextNode; + transformedNode?: TextNode; nodeBefore: TextNode | undefined; // If split nodeAfter: TextNode | undefined; // If split } | null { @@ -87,17 +95,14 @@ export function importFoundTextMatchTransformer( ); } - if (!transformer?.replace) { + if (!transformer.replace) { return null; } - const potentialTransformedNode: any = transformer.replace( - transformedNode, - match, - ); + const potentialTransformedNode = transformer.replace(transformedNode, match); return { nodeAfter, nodeBefore, - transformedNode: potentialTransformedNode, + transformedNode: potentialTransformedNode ?? undefined, }; } diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 0c3b0d2560a..2bfa6da4d38 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -1,6 +1,15 @@ -import {$isTextNode, type TextNode} from 'lexical'; +/** + * 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 TextNode} from 'lexical'; + import { findOutermostTextFormatTransformer, importTextFormatTransformer, @@ -55,7 +64,7 @@ export function importTextTransformers( ); if ( - result?.nodeAfter && + result.nodeAfter && $isTextNode(result.nodeAfter) && !result.nodeAfter.hasFormat('code') ) { @@ -66,7 +75,7 @@ export function importTextTransformers( ); } if ( - result?.nodeBefore && + result.nodeBefore && $isTextNode(result.nodeBefore) && !result.nodeBefore.hasFormat('code') ) { @@ -77,7 +86,7 @@ export function importTextTransformers( ); } if ( - result?.transformedNode && + result.transformedNode && $isTextNode(result.transformedNode) && !result.transformedNode.hasFormat('code') ) { @@ -96,8 +105,12 @@ export function importTextTransformers( foundTextMatch.transformer, foundTextMatch.match, ); + if (!result) { + return; + } + if ( - result?.nodeAfter && + result.nodeAfter && $isTextNode(result.nodeAfter) && !result.nodeAfter.hasFormat('code') ) { @@ -108,7 +121,7 @@ export function importTextTransformers( ); } if ( - result?.nodeBefore && + result.nodeBefore && $isTextNode(result.nodeBefore) && !result.nodeBefore.hasFormat('code') ) { @@ -119,7 +132,7 @@ export function importTextTransformers( ); } if ( - result?.transformedNode && + result.transformedNode && $isTextNode(result.transformedNode) && !result.transformedNode.hasFormat('code') ) { From b66b8cc94c8b205a1a14bf4a5aaa8fb8451f8a6e Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 11:52:02 -0700 Subject: [PATCH 09/20] fix build --- packages/lexical-markdown/src/importTextMatchTransformer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index 48086661779..074b1976150 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -103,6 +103,6 @@ export function importFoundTextMatchTransformer( return { nodeAfter, nodeBefore, - transformedNode: potentialTransformedNode ?? undefined, + transformedNode: potentialTransformedNode || undefined, }; } From 4dbded16718b51533fbbfbcbbdbbd52cfb418551 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 11:59:51 -0700 Subject: [PATCH 10/20] fix build --- packages/lexical-markdown/src/importTextFormatTransformer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index ca725266f15..7abdd37b63f 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -28,7 +28,7 @@ export function findOutermostTextFormatTransformer( return null; } - const textFormatMatchStart: number = match.index ?? 0; + const textFormatMatchStart: number = match.index || 0; const textFormatMatchEnd = textFormatMatchStart + match[0].length; const transformer: TextFormatTransformer = From db80558186e5cbf5ca08740979b0848cdd18f497 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 13:53:28 -0700 Subject: [PATCH 11/20] fix: node tags opened before the link node were incorrectly closed within the link node text --- .../lexical-markdown/src/MarkdownExport.ts | 51 ++++++++++++++++--- .../__tests__/unit/LexicalMarkdown.test.ts | 14 ++++- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index cbd60bdf8cb..8c3da28774b 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -106,6 +106,7 @@ function exportChildren( textTransformersIndex: Array, textMatchTransformers: Array, unclosedTags?: Array<{format: TextFormatType; tag: string}>, + unclosableTags?: Array<{format: TextFormatType; tag: string}>, ): string { const output = []; const children = node.getChildren(); @@ -113,6 +114,9 @@ function exportChildren( if (!unclosedTags) { unclosedTags = []; } + if (!unclosableTags) { + unclosableTags = []; + } mainLoop: for (const child of children) { for (const transformer of textMatchTransformers) { @@ -128,6 +132,12 @@ function exportChildren( 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( @@ -135,6 +145,7 @@ function exportChildren( textContent, textTransformersIndex, unclosedTags, + unclosableTags, ), ); @@ -153,6 +164,7 @@ function exportChildren( child.getTextContent(), textTransformersIndex, unclosedTags, + unclosableTags, ), ); } else if ($isElementNode(child)) { @@ -163,6 +175,7 @@ function exportChildren( textTransformersIndex, textMatchTransformers, unclosedTags, + unclosableTags, ), ); } else if ($isDecoratorNode(child)) { @@ -179,6 +192,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 **" @@ -189,7 +203,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); @@ -219,23 +234,45 @@ 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) { + closingTagsBefore += unclosedTag.tag; + } else { + 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/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index dfdfc8e3339..e4c4aa69559 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'; @@ -552,6 +553,16 @@ describe('Markdown', () => { 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~~***!', + }, ]; const HIGHLIGHT_TEXT_MATCH_IMPORT: TextMatchTransformer = { @@ -616,6 +627,7 @@ describe('Markdown', () => { skipExport, shouldPreserveNewLines, customTransformers, + mdAfterExport, } of IMPORT_AND_EXPORT) { if (skipExport) { continue; @@ -656,7 +668,7 @@ describe('Markdown', () => { shouldPreserveNewLines, ), ), - ).toBe(md); + ).toBe(mdAfterExport ?? md); }); } }); From fb1a91cf7e46156faf210fa6f3216d8ba2482b1d Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 13:57:08 -0700 Subject: [PATCH 12/20] add comment explaining logic --- packages/lexical-markdown/src/MarkdownExport.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 8c3da28774b..d381a6c61f5 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -259,8 +259,10 @@ function exportTextFormat( if (unclosedTag && typeof unclosedTag.tag === 'string') { 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 { + } else if (!nextNodeHasFormat) { closingTagsAfter += unclosedTag.tag; } } From d4dbf73f2ad4a6ca2895d15042d7b1c21ae11307 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 30 Dec 2024 14:23:56 -0700 Subject: [PATCH 13/20] fix: consecutive text match was not processed --- .../src/__tests__/unit/LexicalMarkdown.test.ts | 4 ++++ packages/lexical-markdown/src/importTextMatchTransformer.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index e4c4aa69559..1b37957213d 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -563,6 +563,10 @@ describe('Markdown', () => { 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)', + }, ]; const HIGHLIGHT_TEXT_MATCH_IMPORT: TextMatchTransformer = { diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index 074b1976150..91651bd9338 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -87,7 +87,7 @@ export function importFoundTextMatchTransformer( let transformedNode, nodeAfter, nodeBefore; if (startIndex === 0) { - [transformedNode, textNode] = textNode.splitText(endIndex); + [transformedNode, nodeAfter] = textNode.splitText(endIndex); } else { [nodeBefore, transformedNode, nodeAfter] = textNode.splitText( startIndex, From 8f051adf52889fdb78a7f4c767b132fe4b1fc421 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 6 Jan 2025 22:41:57 -0700 Subject: [PATCH 14/20] fix: formatted inline code block are not formatted in the correct order --- packages/lexical-markdown/src/MarkdownExport.ts | 17 ++++++++++++++--- .../src/__tests__/unit/LexicalMarkdown.test.ts | 4 ++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index d381a6c61f5..65056016234 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -38,9 +38,20 @@ 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) => { + if (a.format[0] === 'code' && b.format[0] !== 'code') { + return 1; + } else if (a.format[0] !== 'code' && b.format[0] === 'code') { + return -1; + } else { + return 0; + } + }); return (node) => { const output = []; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 1b37957213d..5ad369ff713 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -567,6 +567,10 @@ describe('Markdown', () => { html: '

linklink2

', md: '[link](https://lexical.dev)[link2](https://lexical.dev)', }, + { + html: '

Bold Code

', + md: '**`Bold Code`**', + }, ]; const HIGHLIGHT_TEXT_MATCH_IMPORT: TextMatchTransformer = { From 7f04ef399b91aff1bd3e438e0e5778c89976a787 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 6 Jan 2025 22:57:25 -0700 Subject: [PATCH 15/20] more reliable fix --- packages/lexical-markdown/src/MarkdownExport.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 65056016234..55a9a6caa66 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -44,9 +44,9 @@ export function createMarkdownExport( // 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) => { - if (a.format[0] === 'code' && b.format[0] !== 'code') { + if (a.format.includes('code') && !b.format.includes('code')) { return 1; - } else if (a.format[0] !== 'code' && b.format[0] === 'code') { + } else if (!a.format.includes('code') && b.format.includes('code')) { return -1; } else { return 0; From 4fd26bbe91eaf2fe7fa25294ef354ba25ae67b13 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 20 Jan 2025 22:48:27 -0700 Subject: [PATCH 16/20] perf: less array.include calls --- packages/lexical-markdown/src/MarkdownExport.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 55a9a6caa66..ea6d3ef700d 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -44,13 +44,9 @@ export function createMarkdownExport( // 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) => { - if (a.format.includes('code') && !b.format.includes('code')) { - return 1; - } else if (!a.format.includes('code') && b.format.includes('code')) { - return -1; - } else { - return 0; - } + return ( + Number(a.format.includes('code')) - Number(b.format.includes('code')) + ); }); return (node) => { From 7987e049498071ffedc1490fe1c40fd8cf93d5e0 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 20 Jan 2025 23:03:09 -0700 Subject: [PATCH 17/20] perf: shared canContainTransformableMarkdown function, avoid editor.update call if node can not contain transformable markdown --- .../lexical-markdown/src/MarkdownShortcuts.ts | 8 +-- .../src/importTextTransformers.ts | 50 +++++++------------ 2 files changed, 21 insertions(+), 37 deletions(-) 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/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 2bfa6da4d38..e75c4333d62 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -8,7 +8,7 @@ import type {TextFormatTransformersIndex} from './MarkdownImport'; import type {TextMatchTransformer} from './MarkdownTransformers'; -import {$isTextNode, type TextNode} from 'lexical'; +import {$isTextNode, type LexicalNode, type TextNode} from 'lexical'; import { findOutermostTextFormatTransformer, @@ -19,6 +19,18 @@ import { 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, @@ -63,33 +75,21 @@ export function importTextTransformers( foundTextFormat.match, ); - if ( - result.nodeAfter && - $isTextNode(result.nodeAfter) && - !result.nodeAfter.hasFormat('code') - ) { + if (canContainTransformableMarkdown(result.nodeAfter)) { importTextTransformers( result.nodeAfter, textFormatTransformersIndex, textMatchTransformers, ); } - if ( - result.nodeBefore && - $isTextNode(result.nodeBefore) && - !result.nodeBefore.hasFormat('code') - ) { + if (canContainTransformableMarkdown(result.nodeBefore)) { importTextTransformers( result.nodeBefore, textFormatTransformersIndex, textMatchTransformers, ); } - if ( - result.transformedNode && - $isTextNode(result.transformedNode) && - !result.transformedNode.hasFormat('code') - ) { + if (canContainTransformableMarkdown(result.transformedNode)) { importTextTransformers( result.transformedNode, textFormatTransformersIndex, @@ -109,33 +109,21 @@ export function importTextTransformers( return; } - if ( - result.nodeAfter && - $isTextNode(result.nodeAfter) && - !result.nodeAfter.hasFormat('code') - ) { + if (canContainTransformableMarkdown(result.nodeAfter)) { importTextTransformers( result.nodeAfter, textFormatTransformersIndex, textMatchTransformers, ); } - if ( - result.nodeBefore && - $isTextNode(result.nodeBefore) && - !result.nodeBefore.hasFormat('code') - ) { + if (canContainTransformableMarkdown(result.nodeBefore)) { importTextTransformers( result.nodeBefore, textFormatTransformersIndex, textMatchTransformers, ); } - if ( - result.transformedNode && - $isTextNode(result.transformedNode) && - !result.transformedNode.hasFormat('code') - ) { + if (canContainTransformableMarkdown(result.transformedNode)) { importTextTransformers( result.transformedNode, textFormatTransformersIndex, From a0898cdedde10b68bdcc97febabbd91422218681 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 20 Jan 2025 23:19:24 -0700 Subject: [PATCH 18/20] sanitize link title --- packages/lexical-markdown/src/MarkdownTransformers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index f22067abc0c..39b7d7f605a 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -548,7 +548,10 @@ export const LINK: TextMatchTransformer = { const textContent = exportChildren(node); const linkContent = title - ? `[${textContent}](${node.getURL()} "${title}")` + ? `[${textContent}](${node.getURL()} "${title + // Escape quotes and backslashes in the title so that it doesn't break the markdown + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"')}")` : `[${textContent}](${node.getURL()})`; return linkContent; From 51bf22b57c64f1a1201aa81bbae60ac2e586c35c Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 20 Jan 2025 23:38:13 -0700 Subject: [PATCH 19/20] undo change --- packages/lexical-markdown/src/MarkdownTransformers.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index 39b7d7f605a..f22067abc0c 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -548,10 +548,7 @@ export const LINK: TextMatchTransformer = { const textContent = exportChildren(node); const linkContent = title - ? `[${textContent}](${node.getURL()} "${title - // Escape quotes and backslashes in the title so that it doesn't break the markdown - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"')}")` + ? `[${textContent}](${node.getURL()} "${title}")` : `[${textContent}](${node.getURL()})`; return linkContent; From 0de5cc1b3c8c19f1f81e10b0d45dca593c9b9999 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 28 Jan 2025 15:28:11 -0700 Subject: [PATCH 20/20] fix tests --- packages/lexical-markdown/src/MarkdownShortcuts.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/lexical-markdown/src/MarkdownShortcuts.ts b/packages/lexical-markdown/src/MarkdownShortcuts.ts index 01f1e808fc6..90213cd774f 100644 --- a/packages/lexical-markdown/src/MarkdownShortcuts.ts +++ b/packages/lexical-markdown/src/MarkdownShortcuts.ts @@ -498,7 +498,7 @@ export function registerMarkdownShortcuts( const anchorNode = editorState._nodeMap.get(anchorKey); if ( - !canContainTransformableMarkdown(anchorNode) || + !$isTextNode(anchorNode) || !dirtyLeaves.has(anchorKey) || (anchorOffset !== 1 && anchorOffset > prevSelection.anchor.offset + 1) ) { @@ -506,6 +506,10 @@ export function registerMarkdownShortcuts( } editor.update(() => { + if (!canContainTransformableMarkdown(anchorNode)) { + return; + } + const parentNode = anchorNode.getParent(); if (parentNode === null || $isCodeNode(parentNode)) {