From 63f79859be2e1d124a6b2648fc71145ae14f34fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 16:44:16 +0000 Subject: [PATCH 01/47] [lexical-mdast][lexical-playground][lexical-website] Bug Fix: Write line breaks in Markdown table cells as
MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description A GFM table cell holds a single line, so line breaks in a cell need the `
` convention that GitHub and most renderers accept there. - The playground's `TABLE` Markdown transformer wrote a literal `\n` for each newline in a cell (`one\n\ntwo`), which no renderer reads. It now writes `
`, escapes `|` in cell text, reads `
`, `
` and `
` back (the legacy `\n` too), splits rows only on unescaped pipes, and trims cell padding on import. - `@lexical/mdast`'s `MdastTableExtension` exported a cell's paragraphs and line breaks as spaces, so the lines were lost, and fused the items of a list in a cell (`xy`). It now writes each paragraph, line break, list item and code line as its own `
`-separated line, and imports `
` as a paragraph boundary (nested in formatting, as a line break). Without this, `
` imported as literal text and exported as `\
`. - Column alignment in `@lexical/mdast` was stored as one array on the table, so deleting or inserting a column shifted every alignment after it. It is now stored on each cell of the column; the old table-level state is still read for existing documents. - The mdast-editor dev example gets table editing: `TableExtension` (with the features GFM can't express turned off), Insert > Table, a Table menu for adding and removing rows and columns, table styles, and a Tables section in the demo document. Closes #9323 ## Test plan ### Before ``` $ vitest run --project unit packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts × writes line breaks in a cell as
and escapes pipes × reads
, escaped pipes and the legacy \n back × keeps each column its alignment when a column is removed × gives an inserted column no alignment × joins multi-paragraph cells instead of fusing their text × writes line breaks in a cell as
, never a newline × reads
in a cell as a paragraph boundary AssertionError: expected '| a | b |\n| --- | --- |\n| one\n\ntw…' to be '| a | b |\n| --- | --- |\n| one
tw…' AssertionError: expected '| b |\n| :- |\n| 2 |' to be '| b |\n| -: |\n| 2 |' AssertionError: expected 'a b' to be 'a
b' AssertionError: expected [ 'x
y

z
w' ] to deeply equal [ 'x', 'y', '', 'z\nw' ] Tests 7 failed | 103 passed (110) ``` ### After ``` $ vitest run --project unit packages/lexical-mdast packages/lexical-playground dev-examples Test Files 40 passed (40) Tests 624 passed (624) $ vitest run --project browser dev-examples packages/lexical-mdast Tests 36 passed (36) $ tsc -p . # exit 0 ``` The new browser test `dev-examples/mdast-editor/src/__tests__/browser/TableEdit.test.ts` covers the Table menu's commands and pressing Enter in a cell, which exports `| 1 | 2
3 |`. Browser tests ran in Chromium only; Firefox and WebKit were not exercised. Co-Authored-By: Claude Opus 5.5 (1M context) --- dev-examples/mdast-editor/package.json | 1 + dev-examples/mdast-editor/src/Editor.tsx | 22 ++ .../src/__tests__/browser/TableEdit.test.ts | 123 +++++++++ .../src/extensions/MdastEditorExtension.ts | 4 + .../src/extensions/TableEditExtension.ts | 113 ++++++++ .../src/extensions/ToolbarStateExtension.ts | 13 +- .../src/plugins/ToolbarPlugin.tsx | 47 ++++ .../lexical-mdast/src/MdastTableExtension.ts | 252 ++++++++++++++++-- .../__tests__/unit/MdastImportExport.test.ts | 150 ++++++++++- .../unit/MarkdownTransformers.test.ts | 105 ++++++++ .../src/plugins/MarkdownTransformers/index.ts | 32 ++- .../docs/serialization/markdown-mdast.md | 6 + pnpm-lock.yaml | 3 + 13 files changed, 834 insertions(+), 37 deletions(-) create mode 100644 dev-examples/mdast-editor/src/__tests__/browser/TableEdit.test.ts create mode 100644 dev-examples/mdast-editor/src/extensions/TableEditExtension.ts diff --git a/dev-examples/mdast-editor/package.json b/dev-examples/mdast-editor/package.json index 967b7243fd9..1aaab4d3092 100644 --- a/dev-examples/mdast-editor/package.json +++ b/dev-examples/mdast-editor/package.json @@ -24,6 +24,7 @@ "@lexical/react": "workspace:*", "@lexical/rich-text": "workspace:*", "@lexical/selection": "workspace:*", + "@lexical/table": "workspace:*", "@lexical/utils": "workspace:*", "lexical": "workspace:*", "mdast-util-gfm-footnote": "^2.1.0", diff --git a/dev-examples/mdast-editor/src/Editor.tsx b/dev-examples/mdast-editor/src/Editor.tsx index f59224725bf..25efb617556 100644 --- a/dev-examples/mdast-editor/src/Editor.tsx +++ b/dev-examples/mdast-editor/src/Editor.tsx @@ -70,6 +70,19 @@ Type \`[^another]\` to mint one. - [x] Syntax preserved via NodeState - [ ] Ship it +## Tables + +GFM tables edit as \`@lexical/table\` nodes: Tab moves between cells, and +the **Table** menu in the toolbar adds or removes rows and columns while +the cursor is in one. A cell holds a single line in Markdown, so Enter in a +cell becomes \`
\`: + +| Construct | Syntax | Notes | +| :-- | :-: | --: | +| Strikethrough | \`~~text~~\` | GFM | +| Task list | \`- [ ] \` | GFM | +| Line break | \`
\` | One line
per paragraph | + ## Alerts GitHub-style alerts are plain blockquotes with a \`[!TYPE]\` marker: the @@ -132,6 +145,15 @@ const theme = { paragraph: 'my-1', quote: 'my-2 border-l-4 border-solid border-zinc-300 pl-3 text-zinc-600 dark:border-zinc-600 dark:text-zinc-300', + // @lexical/table: the scroll wrapper holds the margin, cells get a grid, + // and a multi-cell selection is drawn on the cells instead of the text. + table: 'border-collapse border-spacing-0', + tableCell: + 'relative min-w-[75px] border border-solid border-zinc-300 px-2 py-0.5 text-start align-top dark:border-zinc-600', + tableCellHeader: 'bg-zinc-100 font-semibold dark:bg-zinc-700', + tableCellSelected: 'bg-blue-100 caret-transparent dark:bg-blue-900/60', + tableScrollableWrapper: 'my-2 overflow-x-auto', + tableSelection: 'selection:bg-transparent', text: { bold: 'font-bold', code: 'rounded bg-zinc-200/70 px-1 py-0.5 font-mono text-[0.9em] dark:bg-zinc-700/60', diff --git a/dev-examples/mdast-editor/src/__tests__/browser/TableEdit.test.ts b/dev-examples/mdast-editor/src/__tests__/browser/TableEdit.test.ts new file mode 100644 index 00000000000..7fa2c6cba3b --- /dev/null +++ b/dev-examples/mdast-editor/src/__tests__/browser/TableEdit.test.ts @@ -0,0 +1,123 @@ +/** + * 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 { + buildEditorFromExtensions, + effect, + getExtensionDependencyFromEditor, +} from '@lexical/extension'; +import { + $convertFromMarkdownString, + $convertToMarkdownString, +} from '@lexical/mdast'; +import {$isTableCellNode, INSERT_TABLE_COMMAND} from '@lexical/table'; +import { + $getRoot, + $isElementNode, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; +import {expect, onTestFinished, test} from 'vitest'; +import {userEvent} from 'vitest/browser'; + +import { + TABLE_EDIT_COMMAND, + type TableEdit, +} from '../../extensions/TableEditExtension'; +import {ToolbarStateExtension} from '../../extensions/ToolbarStateExtension'; + +const TABLE = '| a | b |\n| - | - |\n| 1 | 2 |'; + +function mountEditor(markdown: string) { + const root = document.createElement('div'); + root.contentEditable = 'true'; + document.body.appendChild(root); + const editor = buildEditorFromExtensions(ToolbarStateExtension); + editor.setRootElement(root); + onTestFinished(() => { + editor.dispose(); + root.remove(); + }); + const toolbar = getExtensionDependencyFromEditor( + editor, + ToolbarStateExtension, + ).output; + // Subscribe like ToolbarPlugin so the watched editor-state signal updates. + onTestFinished( + effect(() => { + void toolbar.isInTable.value; + }), + ); + editor.update(() => $convertFromMarkdownString(markdown), {discrete: true}); + return {editor, root, toolbar}; +} + +function exportMarkdown(editor: LexicalEditor): string { + return editor.read(() => $convertToMarkdownString()); +} + +/** Puts the caret at the end of the last cell of the last table. */ +function selectLastCell(editor: LexicalEditor) { + editor.update( + () => { + let node: LexicalNode | null = $getRoot().getLastChild(); + while ($isElementNode(node) && !$isTableCellNode(node)) { + node = node.getLastChild(); + } + if ($isTableCellNode(node)) { + node.selectEnd(); + } + }, + {discrete: true}, + ); +} + +test('the toolbar sees when the selection is in a table', () => { + const {editor, toolbar} = mountEditor(`intro\n\n${TABLE}`); + editor.update(() => $getRoot().selectStart(), {discrete: true}); + expect(toolbar.isInTable.value).toBe(false); + selectLastCell(editor); + expect(toolbar.isInTable.value).toBe(true); +}); + +test.each<[TableEdit, string]>([ + ['row-above', '| a | b |\n| - | - |\n| | |\n| 1 | 2 |'], + ['row-below', '| a | b |\n| - | - |\n| 1 | 2 |\n| | |'], + ['column-left', '| a | | b |\n| - | - | - |\n| 1 | | 2 |'], + ['column-right', '| a | b | |\n| - | - | - |\n| 1 | 2 | |'], + ['delete-row', '| a | b |\n| - | - |'], + ['delete-column', '| a |\n| - |\n| 1 |'], + ['delete-table', ''], +])('TABLE_EDIT_COMMAND %s', (edit, expected) => { + const {editor} = mountEditor(TABLE); + selectLastCell(editor); + editor.dispatchCommand(TABLE_EDIT_COMMAND, edit); + expect(exportMarkdown(editor)).toBe(expected); +}); + +test('an inserted table exports as GFM with a header row', () => { + const {editor} = mountEditor(''); + editor.update(() => $getRoot().selectEnd(), {discrete: true}); + editor.dispatchCommand(INSERT_TABLE_COMMAND, { + columns: '2', + includeHeaders: {columns: false, rows: true}, + rows: '2', + }); + expect(exportMarkdown(editor)).toContain('| | |\n| - | - |\n| | |'); +}); + +// https://github.com/facebook/lexical/issues/9323 +test('Enter in a cell exports as
, not a newline', async () => { + const {editor, root} = mountEditor(TABLE); + root.focus(); + selectLastCell(editor); + await userEvent.keyboard('{Enter}3'); + expect(exportMarkdown(editor)).toBe( + '| a | b |\n| - | ------ |\n| 1 | 2
3 |', + ); +}); diff --git a/dev-examples/mdast-editor/src/extensions/MdastEditorExtension.ts b/dev-examples/mdast-editor/src/extensions/MdastEditorExtension.ts index 905de2bcdd8..03fb2c67596 100644 --- a/dev-examples/mdast-editor/src/extensions/MdastEditorExtension.ts +++ b/dev-examples/mdast-editor/src/extensions/MdastEditorExtension.ts @@ -63,6 +63,7 @@ import { } from './MdastCollapsibleExtension'; import {MdastFootnoteExtension} from './MdastFootnoteExtension'; import {MdastKbdExtension} from './MdastKbdExtension'; +import {TableEditExtension} from './TableEditExtension'; /** * Reformats the current selection's blocks as a paragraph. Toolbars @@ -146,6 +147,9 @@ export const MdastEditorExtension = defineExtension({ RichTextExtension, ListExtension, CheckListExtension, + // GFM tables (imported and exported by MdastGfmExtension) get the + // @lexical/table editing behavior and the toolbar's structure commands. + TableEditExtension, HistoryExtension, TabIndentationExtension, EditorStateExtension, diff --git a/dev-examples/mdast-editor/src/extensions/TableEditExtension.ts b/dev-examples/mdast-editor/src/extensions/TableEditExtension.ts new file mode 100644 index 00000000000..1bab50f6344 --- /dev/null +++ b/dev-examples/mdast-editor/src/extensions/TableEditExtension.ts @@ -0,0 +1,113 @@ +/** + * 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 { + $deleteTableColumnAtSelection, + $deleteTableRowAtSelection, + $findTableNode, + $insertTableColumnAtSelection, + $insertTableRowAtSelection, + $isTableSelection, + TableExtension, + type TableNode, +} from '@lexical/table'; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_EDITOR, + configExtension, + createCommand, + defineExtension, + type LexicalCommand, +} from 'lexical'; + +export type TableEdit = + | 'row-above' + | 'row-below' + | 'column-left' + | 'column-right' + | 'delete-row' + | 'delete-column' + | 'delete-table'; + +/** + * Edits the structure of the table the selection is in. The toolbar + * dispatches this rather than calling the `@lexical/table` helpers itself, + * so this extension owns table editing behavior. + */ +export const TABLE_EDIT_COMMAND: LexicalCommand = + createCommand('TABLE_EDIT_COMMAND'); + +/** + * The table the current selection is in, or null when it is outside one. + */ +export function $getSelectedTable(): TableNode | null { + const selection = $getSelection(); + if (!$isRangeSelection(selection) && !$isTableSelection(selection)) { + return null; + } + const anchorTable = $findTableNode(selection.anchor.getNode()); + return anchorTable !== null && + anchorTable.is($findTableNode(selection.focus.getNode())) + ? anchorTable + : null; +} + +/** + * GFM tables, as `@lexical/table` nodes with the table editing behavior + * (cell selection, Tab navigation, horizontal scroll) and structure + * commands. Markdown tables have a header row and plain cells, so the + * features GFM can't express (merged cells, cell background colors, nested + * tables) are turned off rather than silently dropped on export. + */ +export const TableEditExtension = defineExtension({ + dependencies: [ + configExtension(TableExtension, { + hasCellBackgroundColor: false, + hasCellMerge: false, + hasNestedTables: false, + }), + ], + name: '@lexical/dev-mdast-editor-example/TableEdit', + register(editor) { + return editor.registerCommand( + TABLE_EDIT_COMMAND, + edit => { + const table = $getSelectedTable(); + if (!editor.isEditable() || table === null) { + return false; + } + switch (edit) { + case 'row-above': + case 'row-below': + $insertTableRowAtSelection(edit === 'row-below'); + break; + case 'column-left': + case 'column-right': + $insertTableColumnAtSelection(edit === 'column-right'); + break; + case 'delete-row': + $deleteTableRowAtSelection(); + break; + case 'delete-column': + $deleteTableColumnAtSelection(); + break; + case 'delete-table': { + const paragraph = $createParagraphNode(); + table.replace(paragraph); + paragraph.select(); + break; + } + } + return true; + }, + COMMAND_PRIORITY_EDITOR, + ); + }, +}); diff --git a/dev-examples/mdast-editor/src/extensions/ToolbarStateExtension.ts b/dev-examples/mdast-editor/src/extensions/ToolbarStateExtension.ts index d6427ec4f19..31050df5def 100644 --- a/dev-examples/mdast-editor/src/extensions/ToolbarStateExtension.ts +++ b/dev-examples/mdast-editor/src/extensions/ToolbarStateExtension.ts @@ -13,6 +13,7 @@ import {$isHeadingNode} from '@lexical/rich-text'; import {$getSelection, $isRangeSelection, defineExtension} from 'lexical'; import {MdastEditorExtension} from './MdastEditorExtension'; +import {$getSelectedTable} from './TableEditExtension'; export type BlockType = | 'paragraph' @@ -28,19 +29,25 @@ interface ToolbarSelectionState { isBold: boolean; isItalic: boolean; isCode: boolean; + isInTable: boolean; } const DEFAULT_SELECTION_STATE: ToolbarSelectionState = { blockType: 'paragraph', isBold: false, isCode: false, + isInTable: false, isItalic: false, }; function $readSelectionState(): ToolbarSelectionState { const selection = $getSelection(); if (!$isRangeSelection(selection)) { - return DEFAULT_SELECTION_STATE; + // A multi-cell TableSelection still edits its table. + const isInTable = $getSelectedTable() !== null; + return isInTable + ? {...DEFAULT_SELECTION_STATE, isInTable} + : DEFAULT_SELECTION_STATE; } const anchorNode = selection.anchor.getNode(); // Deleting all content can leave the selection on the root, which has no @@ -59,6 +66,7 @@ function $readSelectionState(): ToolbarSelectionState { blockType, isBold: selection.hasFormat('bold'), isCode: selection.hasFormat('code'), + isInTable: $getSelectedTable() !== null, isItalic: selection.hasFormat('italic'), }; } @@ -68,7 +76,7 @@ function $readSelectionState(): ToolbarSelectionState { * derived as `computed()` signals off the existing extension outputs: * * - The selection-derived signals (`blockType`, `isBold`, `isItalic`, - * `isCode`) are computed off the {@link EditorStateExtension} signal, + * `isCode`, `isInTable`) are computed off the {@link EditorStateExtension} signal, * so they recompute lazily whenever the editor state changes. * - `canUndo` / `canRedo` are computed off the {@link HistoryExtension} * `historyState` signal. The history state object is mutated in @@ -112,6 +120,7 @@ export const ToolbarStateExtension = defineExtension({ }), isBold: computed(() => selection.value.isBold), isCode: computed(() => selection.value.isCode), + isInTable: computed(() => selection.value.isInTable), isItalic: computed(() => selection.value.isItalic), }; }, diff --git a/dev-examples/mdast-editor/src/plugins/ToolbarPlugin.tsx b/dev-examples/mdast-editor/src/plugins/ToolbarPlugin.tsx index 35151290b57..960f4d4927d 100644 --- a/dev-examples/mdast-editor/src/plugins/ToolbarPlugin.tsx +++ b/dev-examples/mdast-editor/src/plugins/ToolbarPlugin.tsx @@ -14,6 +14,7 @@ import { } from '@lexical/list'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {useExtensionSignalValue} from '@lexical/react/useExtensionSignalValue'; +import {INSERT_TABLE_COMMAND} from '@lexical/table'; import { FORMAT_TEXT_COMMAND, type LexicalEditor, @@ -30,6 +31,10 @@ import { } from '../extensions/MdastEditorExtension'; import {INSERT_FOOTNOTE_COMMAND} from '../extensions/MdastFootnoteExtension'; import {FORMAT_KBD_COMMAND} from '../extensions/MdastKbdExtension'; +import { + TABLE_EDIT_COMMAND, + type TableEdit, +} from '../extensions/TableEditExtension'; import { type BlockType, ToolbarStateExtension, @@ -50,8 +55,19 @@ const INSERT_TYPES = [ {label: 'Collapsible section', value: 'details'}, {label: 'Alert', value: 'alert'}, {label: 'Footnote', value: 'footnote'}, + {label: 'Table', value: 'table'}, ] as const; +const TABLE_EDITS: readonly {label: string; value: TableEdit}[] = [ + {label: 'Insert row above', value: 'row-above'}, + {label: 'Insert row below', value: 'row-below'}, + {label: 'Insert column left', value: 'column-left'}, + {label: 'Insert column right', value: 'column-right'}, + {label: 'Delete row', value: 'delete-row'}, + {label: 'Delete column', value: 'delete-column'}, + {label: 'Delete table', value: 'delete-table'}, +]; + type InsertType = (typeof INSERT_TYPES)[number]['value']; function applyInsert(editor: LexicalEditor, type: InsertType): void { @@ -68,6 +84,14 @@ function applyInsert(editor: LexicalEditor, type: InsertType): void { case 'footnote': editor.dispatchCommand(INSERT_FOOTNOTE_COMMAND); return; + case 'table': + // A GFM table always has a header row. + editor.dispatchCommand(INSERT_TABLE_COMMAND, { + columns: '3', + includeHeaders: {columns: false, rows: true}, + rows: '3', + }); + return; } } @@ -118,6 +142,7 @@ export function ToolbarPlugin() { const isBold = useExtensionSignalValue(ToolbarStateExtension, 'isBold'); const isItalic = useExtensionSignalValue(ToolbarStateExtension, 'isItalic'); const isCode = useExtensionSignalValue(ToolbarStateExtension, 'isCode'); + const isInTable = useExtensionSignalValue(ToolbarStateExtension, 'isInTable'); const isEditable = useExtensionSignalValue( MdastEditorExtension, 'isEditable', @@ -203,6 +228,28 @@ export function ToolbarPlugin() { ))} + {isInTable && ( + + )}