diff --git a/packages/lexical-markdown/README.md b/packages/lexical-markdown/README.md index 00f24ffd76e..fdc523266f4 100644 --- a/packages/lexical-markdown/README.md +++ b/packages/lexical-markdown/README.md @@ -81,6 +81,7 @@ LINK And bundles of commonly used transformers: - `TRANSFORMERS` - all built-in transformers - `ELEMENT_TRANSFORMERS` - all built-in element transformers +- `MULTILINE_ELEMENT_TRANSFORMERS` - all built-in multiline element transformers - `TEXT_FORMAT_TRANSFORMERS` - all built-in text format transformers - `TEXT_MATCH_TRANSFORMERS` - all built-in text match transformers diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 40f76590355..491a0db663e 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -6,12 +6,6 @@ * */ -import type { - ElementTransformer, - TextFormatTransformer, - TextMatchTransformer, - Transformer, -} from '@lexical/markdown'; import type {ElementNode, LexicalNode, TextFormatType, TextNode} from 'lexical'; import { @@ -22,6 +16,13 @@ import { $isTextNode, } from 'lexical'; +import { + ElementTransformer, + MultilineElementTransformer, + TextFormatTransformer, + TextMatchTransformer, + Transformer, +} from './MarkdownTransformers'; import {isEmptyParagraph, transformersByType} from './utils'; /** @@ -32,6 +33,7 @@ export function createMarkdownExport( shouldPreserveNewLines: boolean = false, ): (node?: ElementNode) => string { const byType = transformersByType(transformers); + const elementTransformers = [...byType.multilineElement, ...byType.element]; const isNewlineDelimited = !shouldPreserveNewLines; // Export only uses text formats that are responsible for single format @@ -48,14 +50,14 @@ export function createMarkdownExport( const child = children[i]; const result = exportTopLevelElements( child, - byType.element, + elementTransformers, textFormatTransformers, byType.textMatch, ); if (result != null) { output.push( - // seperate consecutive group of texts with a line break: eg. ["hello", "world"] -> ["hello", "/nworld"] + // separate consecutive group of texts with a line break: eg. ["hello", "world"] -> ["hello", "/nworld"] isNewlineDelimited && i > 0 && !isEmptyParagraph(child) && @@ -65,7 +67,7 @@ export function createMarkdownExport( ); } } - // Ensure consecutive groups of texts are atleast \n\n apart while each empty paragraph render as a newline. + // Ensure consecutive groups of texts are at least \n\n apart while each empty paragraph render as a newline. // Eg. ["hello", "", "", "hi", "\nworld"] -> "hello\n\n\nhi\n\nworld" return output.join('\n'); }; @@ -73,11 +75,14 @@ export function createMarkdownExport( function exportTopLevelElements( node: LexicalNode, - elementTransformers: Array, + elementTransformers: Array, textTransformersIndex: Array, textMatchTransformers: Array, ): string | null { for (const transformer of elementTransformers) { + if (!transformer.export) { + continue; + } const result = transformer.export(node, (_node) => exportChildren(_node, textTransformersIndex, textMatchTransformers), ); diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index 7dd242a17c5..3bd73d78d6e 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -6,16 +6,15 @@ * */ -import type {CodeNode} from '@lexical/code'; import type { ElementTransformer, + MultilineElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer, -} from '@lexical/markdown'; +} from './MarkdownTransformers'; import type {TextNode} from 'lexical'; -import {$createCodeNode} from '@lexical/code'; import {$isListItemNode, $isListNode, ListItemNode} from '@lexical/list'; import {$isQuoteNode} from '@lexical/rich-text'; import {$findMatchingParent} from '@lexical/utils'; @@ -36,7 +35,6 @@ import { transformersByType, } from './utils'; -const CODE_BLOCK_REG_EXP = /^[ \t]*```(\w{1,10})?\s?$/; type TextFormatTransformersIndex = Readonly<{ fullMatchRegExpByTag: Readonly>; openTagsRegExp: RegExp; @@ -63,14 +61,20 @@ export function createMarkdownImport( for (let i = 0; i < linesLength; i++) { const lineText = lines[i]; - // Codeblocks are processed first as anything inside such block - // is ignored for further processing - // TODO: - // Abstract it to be dynamic as other transformers (add multiline match option) - const [codeBlockNode, shiftedIndex] = $importCodeBlock(lines, i, root); - - if (codeBlockNode != null) { - i = shiftedIndex; + + const [imported, shiftedIndex] = $importMultiline( + lines, + i, + byType.multilineElement, + root, + ); + + if (imported) { + // If a multiline markdown element was imported, we don't want to process the lines that were part of it anymore. + // There could be other sub-markdown elements (both multiline and normal ones) matching within this matched multiline element's children. + // However, it would be the responsibility of the matched multiline transformer to decide how it wants to handle them. + // We cannot handle those, as there is no way for us to know how to maintain the correct order of generated lexical nodes for possible children. + i = shiftedIndex; // Next loop will start from the line after the last line of the multiline element continue; } @@ -103,6 +107,108 @@ export function createMarkdownImport( }; } +/** + * + * @returns first element of the returned tuple is a boolean indicating if a multiline element was imported. The second element is the index of the last line that was processed. + */ +function $importMultiline( + lines: Array, + startLineIndex: number, + multilineElementTransformers: Array, + rootNode: ElementNode, +): [boolean, number] { + for (const { + regExpStart, + regExpEnd, + replace, + } of multilineElementTransformers) { + const startMatch = lines[startLineIndex].match(regExpStart); + if (!startMatch) { + continue; // Try next transformer + } + + const regexpEndRegex: RegExp | undefined = + typeof regExpEnd === 'object' && 'regExp' in regExpEnd + ? regExpEnd.regExp + : regExpEnd; + + const isEndOptional = + regExpEnd && typeof regExpEnd === 'object' && 'optional' in regExpEnd + ? regExpEnd.optional + : !regExpEnd; + + let endLineIndex = startLineIndex; + const linesLength = lines.length; + + // check every single line for the closing match. It could also be on the same line as the opening match. + while (endLineIndex < linesLength) { + const endMatch = regexpEndRegex + ? lines[endLineIndex].match(regexpEndRegex) + : null; + if (!endMatch) { + if ( + !isEndOptional || + (isEndOptional && endLineIndex < linesLength - 1) // Optional end, but didn't reach the end of the document yet => continue searching for potential closing match + ) { + endLineIndex++; + continue; // Search next line for closing match + } + } + + // Now, check if the closing match matched is the same as the opening match. + // If it is, we need to continue searching for the actual closing match. + if ( + endMatch && + startLineIndex === endLineIndex && + endMatch.index === startMatch.index + ) { + endLineIndex++; + continue; // Search next line for closing match + } + + // At this point, we have found the closing match. Next: calculate the lines in between open and closing match + // This should not include the matches themselves, and be split up by lines + const linesInBetween = []; + + if (endMatch && startLineIndex === endLineIndex) { + linesInBetween.push( + lines[startLineIndex].slice( + startMatch[0].length, + -endMatch[0].length, + ), + ); + } else { + for (let i = startLineIndex; i <= endLineIndex; i++) { + if (i === startLineIndex) { + const text = lines[i].slice(startMatch[0].length); + linesInBetween.push(text); // Also include empty text + } else if (i === endLineIndex && endMatch) { + const text = lines[i].slice(0, -endMatch[0].length); + linesInBetween.push(text); // Also include empty text + } else { + linesInBetween.push(lines[i]); + } + } + } + + if ( + replace(rootNode, null, startMatch, endMatch, linesInBetween, true) !== + false + ) { + // Return here. This $importMultiline function is run line by line and should only process a single multiline element at a time. + return [true, endLineIndex]; + } + + // The replace function returned false, despite finding the matching open and close tags => this transformer does not want to handle it. + // Thus, we continue letting the remaining transformers handle the passed lines of text from the beginning + break; + } + } + + // No multiline transformer handled this line successfully + return [false, startLineIndex]; +} + function $importBlocks( lineText: string, rootNode: ElementNode, @@ -120,8 +226,9 @@ function $importBlocks( if (match) { textNode.setTextContent(lineText.slice(match[0].length)); - replace(elementNode, [textNode], match, true); - break; + if (replace(elementNode, [textNode], match, true) !== false) { + break; + } } } @@ -163,35 +270,6 @@ function $importBlocks( } } -function $importCodeBlock( - lines: Array, - startLineIndex: number, - rootNode: ElementNode, -): [CodeNode | null, number] { - const openMatch = lines[startLineIndex].match(CODE_BLOCK_REG_EXP); - - if (openMatch) { - let endLineIndex = startLineIndex; - const linesLength = lines.length; - - while (++endLineIndex < linesLength) { - const closeMatch = lines[endLineIndex].match(CODE_BLOCK_REG_EXP); - - if (closeMatch) { - const codeBlockNode = $createCodeNode(openMatch[1]); - const textNode = $createTextNode( - lines.slice(startLineIndex + 1, endLineIndex).join('\n'), - ); - codeBlockNode.append(textNode); - rootNode.append(codeBlockNode); - return [codeBlockNode, endLineIndex]; - } - } - } - - return [null, startLineIndex]; -} - // 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 diff --git a/packages/lexical-markdown/src/MarkdownShortcuts.ts b/packages/lexical-markdown/src/MarkdownShortcuts.ts index 0aee3292720..353fbdd954f 100644 --- a/packages/lexical-markdown/src/MarkdownShortcuts.ts +++ b/packages/lexical-markdown/src/MarkdownShortcuts.ts @@ -8,10 +8,11 @@ import type { ElementTransformer, + MultilineElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer, -} from '@lexical/markdown'; +} from './MarkdownTransformers'; import type {ElementNode, LexicalEditor, TextNode} from 'lexical'; import {$isCodeNode} from '@lexical/code'; @@ -59,15 +60,78 @@ function runElementTransformers( for (const {regExp, replace} of elementTransformers) { const match = textContent.match(regExp); - if (match && match[0].length === anchorOffset) { + if ( + match && + match[0].length === + (match[0].endsWith(' ') ? anchorOffset : anchorOffset - 1) + ) { const nextSiblings = anchorNode.getNextSiblings(); const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset); leadingNode.remove(); const siblings = remainderNode ? [remainderNode, ...nextSiblings] : nextSiblings; - replace(parentNode, siblings, match, false); - return true; + if (replace(parentNode, siblings, match, false) !== false) { + return true; + } + } + } + + return false; +} + +function runMultilineElementTransformers( + parentNode: ElementNode, + anchorNode: TextNode, + anchorOffset: number, + elementTransformers: ReadonlyArray, +): boolean { + const grandParentNode = parentNode.getParent(); + + if ( + !$isRootOrShadowRoot(grandParentNode) || + parentNode.getFirstChild() !== anchorNode + ) { + return false; + } + + const textContent = anchorNode.getTextContent(); + + // Checking for anchorOffset position to prevent any checks for cases when caret is too far + // from a line start to be a part of block-level markdown trigger. + // + // TODO: + // Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20) + // since otherwise it won't be a markdown shortcut, but tables are exception + if (textContent[anchorOffset - 1] !== ' ') { + return false; + } + + for (const {regExpStart, replace, regExpEnd} of elementTransformers) { + if ( + (regExpEnd && !('optional' in regExpEnd)) || + (regExpEnd && 'optional' in regExpEnd && !regExpEnd.optional) + ) { + continue; + } + + const match = textContent.match(regExpStart); + + if ( + match && + match[0].length === + (match[0].endsWith(' ') ? anchorOffset : anchorOffset - 1) + ) { + const nextSiblings = anchorNode.getNextSiblings(); + const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset); + leadingNode.remove(); + const siblings = remainderNode + ? [remainderNode, ...nextSiblings] + : nextSiblings; + + if (replace(parentNode, siblings, match, null, null, false) !== false) { + return true; + } } } @@ -336,7 +400,11 @@ export function registerMarkdownShortcuts( for (const transformer of transformers) { const type = transformer.type; - if (type === 'element' || type === 'text-match') { + if ( + type === 'element' || + type === 'text-match' || + type === 'multilineElement' + ) { const dependencies = transformer.dependencies; for (const node of dependencies) { if (!editor.hasNode(node)) { @@ -366,6 +434,17 @@ export function registerMarkdownShortcuts( return; } + if ( + runMultilineElementTransformers( + parentNode, + anchorNode, + anchorOffset, + byType.multilineElement, + ) + ) { + return; + } + if ( runTextMatchTransformers( anchorNode, diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index ebc1b448dbc..fc0662726ae 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -40,26 +40,96 @@ import { export type Transformer = | ElementTransformer + | MultilineElementTransformer | TextFormatTransformer | TextMatchTransformer; export type ElementTransformer = { dependencies: Array>; + /** + * `export` is called when the `$convertToMarkdownString` is called to convert the editor state into markdown. + * + * @return return null to cancel the export, even though the regex matched. Lexical will then search for the next transformer. + */ export: ( node: LexicalNode, // eslint-disable-next-line no-shadow traverseChildren: (node: ElementNode) => string, ) => string | null; regExp: RegExp; + /** + * `replace` is called when markdown is imported or typed in the editor + * + * @return return false to cancel the transform, even though the regex matched. Lexical will then search for the next transformer. + */ replace: ( parentNode: ElementNode, children: Array, match: Array, + /** + * Whether the match is from an import operation (e.g. through `$convertFromMarkdownString`) or not (e.g. through typing in the editor). + */ isImport: boolean, - ) => void; + ) => boolean | void; type: 'element'; }; +export type MultilineElementTransformer = { + dependencies: Array>; + /** + * `export` is called when the `$convertToMarkdownString` is called to convert the editor state into markdown. + * + * @return return null to cancel the export, even though the regex matched. Lexical will then search for the next transformer. + */ + export?: ( + node: LexicalNode, + // eslint-disable-next-line no-shadow + traverseChildren: (node: ElementNode) => string, + ) => string | null; + /** + * This regex determines when to start matching + */ + regExpStart: RegExp; + /** + * This regex determines when to stop matching. Anything in between regExpStart and regExpEnd will be matched + */ + regExpEnd?: + | RegExp + | { + /** + * Whether the end match is optional. If true, the end match is not required to match for the transformer to be triggered. + * The entire text from regexpStart to the end of the document will then be matched. + */ + optional?: true; + regExp: RegExp; + }; + /** + * `replace` is called only when markdown is imported in the editor, not when it's typed + * + * @return return false to cancel the transform, even though the regex matched. Lexical will then search for the next transformer. + */ + replace: ( + rootNode: ElementNode, + /** + * During markdown shortcut transforms, children nodes may be provided to the transformer. If this is the case, no `linesInBetween` will be provided and + * the children nodes should be used instead of the `linesInBetween` to create the new node. + */ + children: Array | null, + startMatch: Array, + endMatch: Array | null, + /** + * linesInBetween includes the text between the start & end matches, split up by lines, not including the matches themselves. + * This is null when the transformer is triggered through markdown shortcuts (by typing in the editor) + */ + linesInBetween: Array | null, + /** + * Whether the match is from an import operation (e.g. through `$convertFromMarkdownString`) or not (e.g. through typing in the editor). + */ + isImport: boolean, + ) => boolean | void; + type: 'multilineElement'; +}; + export type TextFormatTransformer = Readonly<{ format: ReadonlyArray; tag: string; @@ -241,7 +311,7 @@ export const QUOTE: ElementTransformer = { type: 'element', }; -export const CODE: ElementTransformer = { +export const CODE: MultilineElementTransformer = { dependencies: [CodeNode], export: (node: LexicalNode) => { if (!$isCodeNode(node)) { @@ -256,11 +326,72 @@ export const CODE: ElementTransformer = { '```' ); }, - regExp: /^[ \t]*```(\w{1,10})?\s/, - replace: createBlockNode((match) => { - return $createCodeNode(match ? match[1] : undefined); - }), - type: 'element', + regExpEnd: { + optional: true, + regExp: /[ \t]*```$/, + }, + regExpStart: /^[ \t]*```(\w+)?/, + replace: ( + rootNode, + children, + startMatch, + endMatch, + linesInBetween, + isImport, + ) => { + let codeBlockNode: CodeNode; + let code: string; + + if (!children && linesInBetween) { + if (linesInBetween.length === 1) { + // Single-line code blocks + if (endMatch) { + // End match on same line. Example: ```markdown hello```. markdown should not be considered the language here. + codeBlockNode = $createCodeNode(); + code = startMatch[1] + linesInBetween[0]; + } else { + // No end match. We should assume the language is next to the backticks and that code will be typed on the next line in the future + codeBlockNode = $createCodeNode(startMatch[1]); + code = linesInBetween[0].startsWith(' ') + ? linesInBetween[0].slice(1) + : linesInBetween[0]; + } + } else { + // Treat multi-line code blocks as if they always have an end match + codeBlockNode = $createCodeNode(startMatch[1]); + + if (linesInBetween[0].trim().length === 0) { + // Filter out all start and end lines that are length 0 until we find the first line with content + while (linesInBetween.length > 0 && !linesInBetween[0].length) { + linesInBetween.shift(); + } + } else { + // The first line already has content => Remove the first space of the line if it exists + linesInBetween[0] = linesInBetween[0].startsWith(' ') + ? linesInBetween[0].slice(1) + : linesInBetween[0]; + } + + // Filter out all end lines that are length 0 until we find the last line with content + while ( + linesInBetween.length > 0 && + !linesInBetween[linesInBetween.length - 1].length + ) { + linesInBetween.pop(); + } + + code = linesInBetween.join('\n'); + } + const textNode = $createTextNode(code); + codeBlockNode.append(textNode); + rootNode.append(codeBlockNode); + } else if (children) { + createBlockNode((match) => { + return $createCodeNode(match ? match[1] : undefined); + })(rootNode, children, startMatch, isImport); + } + }, + type: 'multilineElement', }; export const UNORDERED_LIST: ElementTransformer = { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 365f55fe02b..421394fcbf1 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -6,21 +6,54 @@ * */ -import {CodeNode} from '@lexical/code'; +import {$createCodeNode, CodeNode} from '@lexical/code'; import {createHeadlessEditor} from '@lexical/headless'; import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; import {LinkNode} from '@lexical/link'; import {ListItemNode, ListNode} from '@lexical/list'; import {HeadingNode, QuoteNode} from '@lexical/rich-text'; -import {$getRoot, $insertNodes} from 'lexical'; +import {$createTextNode, $getRoot, $insertNodes} from 'lexical'; import { $convertFromMarkdownString, $convertToMarkdownString, LINK, TextMatchTransformer, + Transformer, TRANSFORMERS, } from '../..'; +import {MultilineElementTransformer} from '../../MarkdownTransformers'; + +// Matches html within a mdx file +const MDX_HTML_TRANSFORMER: MultilineElementTransformer = { + dependencies: [CodeNode], + export: (node) => { + if (node.getTextContent().startsWith('From HTML:')) { + return `${node + .getTextContent() + .replace('From HTML: ', '')}`; + } + return null; // Run next transformer + }, + regExpEnd: /<\/(\w+)\s*>/, + regExpStart: /<(\w+)[^>]*>/, + replace: (rootNode, children, startMatch, endMatch, linesInBetween) => { + if (!linesInBetween) { + return false; // Run next transformer. We don't need to support markdown shortcuts for this test + } + if (startMatch[1] === 'MyComponent') { + const codeBlockNode = $createCodeNode(startMatch[1]); + const textNode = $createTextNode( + 'From HTML: ' + linesInBetween.join('\n'), + ); + codeBlockNode.append(textNode); + rootNode.append(codeBlockNode); + return; + } + return false; // Run next transformer + }, + type: 'multilineElement', +}; describe('Markdown', () => { type Input = Array<{ @@ -29,6 +62,7 @@ describe('Markdown', () => { skipExport?: true; skipImport?: true; shouldPreserveNewLines?: true; + customTransformers?: Transformer[]; }>; const URL = 'https://lexical.dev'; @@ -68,7 +102,7 @@ describe('Markdown', () => { md: '> Hello\n> world!', }, { - // Miltiline list items + // Multiline list items html: '
  • Hello
  • world
    !
    !
', md: '- Hello\n- world\n!\n!', }, @@ -182,6 +216,24 @@ describe('Markdown', () => { md: 'Hello ~~__*world*__~~!', skipExport: true, }, + { + html: '
Single line Code
', + md: '```Single line Code```', // Ensure that "Single" is not read as the language by the code transformer. It should only be read as the language if there is a multi-line code block + skipExport: true, // Export will fail, as the code transformer will add new lines to the code block to make it multi-line. This is expected though, as the lexical code block is a block node and cannot be inline. + }, + { + html: '
Incomplete tag
', + md: '```javascript Incomplete tag', + skipExport: true, + }, + { + html: + '
Incomplete multiline\n' +
+        '\n' +
+        'Tag
', + md: '```javascript Incomplete multiline\n\nTag', + skipExport: true, + }, { html: '
Code
', md: '```\nCode\n```', @@ -207,6 +259,14 @@ describe('Markdown', () => { md: ' ```\nCode\n```', skipExport: true, }, + { + html: `

Code blocks

1 + 1 = 2;
`, + md: `### Code blocks + +\`\`\`javascript +1 + 1 = 2; +\`\`\``, + }, { // Import only: extra empty lines will be removed for export html: '

Hello

world

', @@ -231,6 +291,16 @@ describe('Markdown', () => { md: "$$H$&e$`l$'l$o$", skipImport: true, }, + { + customTransformers: [MDX_HTML_TRANSFORMER], + html: '

Some HTML in mdx:

From HTML: Some Text
', + md: 'Some HTML in mdx:\n\nSome Text', + }, + { + customTransformers: [MDX_HTML_TRANSFORMER], + html: '

Some HTML in mdx:

From HTML: Line 1\nSome Text
', + md: 'Some HTML in mdx:\n\nLine 1\nSome Text', + }, ]; const HIGHLIGHT_TEXT_MATCH_IMPORT: TextMatchTransformer = { @@ -246,6 +316,7 @@ describe('Markdown', () => { md, skipImport, shouldPreserveNewLines, + customTransformers, } of IMPORT_AND_EXPORT) { if (skipImport) { continue; @@ -267,7 +338,11 @@ describe('Markdown', () => { () => $convertFromMarkdownString( md, - [...TRANSFORMERS, HIGHLIGHT_TEXT_MATCH_IMPORT], + [ + ...(customTransformers || []), + ...TRANSFORMERS, + HIGHLIGHT_TEXT_MATCH_IMPORT, + ], undefined, shouldPreserveNewLines, ), @@ -287,6 +362,7 @@ describe('Markdown', () => { md, skipExport, shouldPreserveNewLines, + customTransformers, } of IMPORT_AND_EXPORT) { if (skipExport) { continue; @@ -322,7 +398,7 @@ describe('Markdown', () => { .getEditorState() .read(() => $convertToMarkdownString( - TRANSFORMERS, + [...(customTransformers || []), ...TRANSFORMERS], undefined, shouldPreserveNewLines, ), diff --git a/packages/lexical-markdown/src/index.ts b/packages/lexical-markdown/src/index.ts index 32864bed542..dac5b260478 100644 --- a/packages/lexical-markdown/src/index.ts +++ b/packages/lexical-markdown/src/index.ts @@ -8,6 +8,7 @@ import type { ElementTransformer, + MultilineElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer, @@ -39,11 +40,14 @@ import { const ELEMENT_TRANSFORMERS: Array = [ HEADING, QUOTE, - CODE, UNORDERED_LIST, ORDERED_LIST, ]; +const MULTILINE_ELEMENT_TRANSFORMERS: Array = [ + CODE, +]; + // Order of text format transformers matters: // // - code should go first as it prevents any transformations inside @@ -64,6 +68,7 @@ const TEXT_MATCH_TRANSFORMERS: Array = [LINK]; const TRANSFORMERS: Array = [ ...ELEMENT_TRANSFORMERS, + ...MULTILINE_ELEMENT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS, ]; @@ -109,22 +114,24 @@ export { CHECK_LIST, CODE, ELEMENT_TRANSFORMERS, - ElementTransformer, + type ElementTransformer, HEADING, HIGHLIGHT, INLINE_CODE, ITALIC_STAR, ITALIC_UNDERSCORE, LINK, + MULTILINE_ELEMENT_TRANSFORMERS, + type MultilineElementTransformer, ORDERED_LIST, QUOTE, registerMarkdownShortcuts, STRIKETHROUGH, TEXT_FORMAT_TRANSFORMERS, TEXT_MATCH_TRANSFORMERS, - TextFormatTransformer, - TextMatchTransformer, - Transformer, + type TextFormatTransformer, + type TextMatchTransformer, + type Transformer, TRANSFORMERS, UNORDERED_LIST, }; diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index ce8f1e3657f..812d61e0269 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -7,12 +7,6 @@ */ import type {ListNode} from '@lexical/list'; -import type { - ElementTransformer, - TextFormatTransformer, - TextMatchTransformer, - Transformer, -} from '@lexical/markdown'; import {$isCodeNode} from '@lexical/code'; import {$isListItemNode, $isListNode} from '@lexical/list'; @@ -25,6 +19,14 @@ import { type TextFormatType, } from 'lexical'; +import { + ElementTransformer, + MultilineElementTransformer, + TextFormatTransformer, + TextMatchTransformer, + Transformer, +} from './MarkdownTransformers'; + type MarkdownFormatKind = | 'noTransformation' | 'paragraphH1' @@ -422,6 +424,7 @@ export function indexBy( export function transformersByType(transformers: Array): Readonly<{ element: Array; + multilineElement: Array; textFormat: Array; textMatch: Array; }> { @@ -429,6 +432,8 @@ export function transformersByType(transformers: Array): Readonly<{ return { element: (byType.element || []) as Array, + multilineElement: (byType.multilineElement || + []) as Array, textFormat: (byType['text-format'] || []) as Array, textMatch: (byType['text-match'] || []) as Array, }; diff --git a/packages/lexical-playground/src/plugins/MarkdownTransformers/index.ts b/packages/lexical-playground/src/plugins/MarkdownTransformers/index.ts index 648669cc122..de5c1e64824 100644 --- a/packages/lexical-playground/src/plugins/MarkdownTransformers/index.ts +++ b/packages/lexical-playground/src/plugins/MarkdownTransformers/index.ts @@ -12,6 +12,7 @@ import { CHECK_LIST, ELEMENT_TRANSFORMERS, ElementTransformer, + MULTILINE_ELEMENT_TRANSFORMERS, TEXT_FORMAT_TRANSFORMERS, TEXT_MATCH_TRANSFORMERS, TextMatchTransformer, @@ -313,6 +314,7 @@ export const PLAYGROUND_TRANSFORMERS: Array = [ TWEET, CHECK_LIST, ...ELEMENT_TRANSFORMERS, + ...MULTILINE_ELEMENT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS, ];