diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index e5ced6d006b..29c15742f51 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -292,7 +292,8 @@ function $importBlocks( function $normalizeMarkdownTextNode(textNode: TextNode): void { // A TabNode is a TextNode whose content is a tab, so without this guard the // rebuild below would destroy it and create an equivalent one in its place. - if ($isTabNode(textNode)) { + // Inline code keeps its tabs as text, so they stay inside the code span. + if ($isTabNode(textNode) || textNode.hasFormat('code')) { return; } const text = textNode.getTextContent(); diff --git a/packages/lexical-markdown/src/__tests__/unit/CodeSpanWhitespaceImport.test.ts b/packages/lexical-markdown/src/__tests__/unit/CodeSpanWhitespaceImport.test.ts new file mode 100644 index 00000000000..964cf5b1616 --- /dev/null +++ b/packages/lexical-markdown/src/__tests__/unit/CodeSpanWhitespaceImport.test.ts @@ -0,0 +1,106 @@ +/** + * 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. + * + */ + +// @vitest-environment node + +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $convertFromMarkdownString, + $convertToMarkdownString, + TRANSFORMERS, +} from '@lexical/markdown'; +import {$getRoot, $isElementNode, $isTabNode, $isTextNode} from 'lexical'; +import {describe, expect, it} from 'vitest'; + +import {MarkdownTestExtension} from '../utils'; + +type Inline = {code: boolean; tab: boolean; text: string}; + +function importParagraphs(markdown: string): Inline[][] { + using editor = buildEditorFromExtensions([MarkdownTestExtension]); + let paragraphs: Inline[][] = []; + editor.update( + () => { + $convertFromMarkdownString(markdown, TRANSFORMERS); + paragraphs = $getRoot() + .getChildren() + .map(block => + $isElementNode(block) + ? block.getChildren().map(child => ({ + code: $isTextNode(child) && child.hasFormat('code'), + tab: $isTabNode(child), + text: child.getTextContent(), + })) + : [], + ); + }, + {discrete: true}, + ); + return paragraphs; +} + +function roundTrip(markdown: string): string { + using editor = buildEditorFromExtensions([MarkdownTestExtension]); + editor.update( + () => { + $convertFromMarkdownString(markdown, TRANSFORMERS); + }, + {discrete: true}, + ); + return editor.read(() => $convertToMarkdownString(TRANSFORMERS)); +} + +const code = (text: string): Inline => ({code: true, tab: false, text}); +const text = (value: string): Inline => ({ + code: false, + tab: false, + text: value, +}); +const tab: Inline = {code: false, tab: true, text: '\t'}; + +describe('whitespace inside inline code (#9409)', () => { + it('keeps a tab inside a code span as part of the code', () => { + expect(importParagraphs('`a\tb`')).toEqual([[code('a\tb')]]); + expect(importParagraphs('a `\t` b')).toEqual([ + [text('a '), code('\t'), text(' b')], + ]); + expect(importParagraphs('`\t`')).toEqual([[code('\t')]]); + }); + + it('round trips a code span that holds a tab', () => { + expect(roundTrip('`a\tb`')).toBe('`a\tb`'); + // A span that starts and ends with whitespace is padded, and a reader + // strips that padding back to the tab. + expect(roundTrip('`\t`')).toBe('` \t `'); + expect(roundTrip('` \t `')).toBe('` \t `'); + expect(roundTrip('a `\t` b')).toBe('a ` \t ` b'); + expect(roundTrip('a ` \t ` b')).toBe('a ` \t ` b'); + }); + + it('keeps a paragraph whose only content is whitespace code', () => { + expect(importParagraphs('x\n\n` `\n\ny')).toEqual([ + [text('x')], + [code(' ')], + [text('y')], + ]); + expect(importParagraphs('x\n\n`\t`\n\ny')).toEqual([ + [text('x')], + [code('\t')], + [text('y')], + ]); + }); + + it('still turns tabs outside code into tab nodes', () => { + expect(importParagraphs('a\tb')).toEqual([[text('a'), tab, text('b')]]); + expect(importParagraphs('a\t`b`')).toEqual([[text('a'), tab, code('b')]]); + }); + + it('still drops empty paragraphs', () => { + expect(importParagraphs('x\n\n\n\ny')).toEqual([[text('x')], [text('y')]]); + }); +}); diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 200b5c10b56..893f6f8fc29 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -72,6 +72,8 @@ export function isEmptyParagraph(node: LexicalNode): boolean { firstChild == null || (node.getChildrenSize() === 1 && $isTextNode(firstChild) && + // Inline code is content even when it holds only whitespace. + !firstChild.hasFormat('code') && MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent())) ); }