Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
63f7985
[lexical-mdast][lexical-playground][lexical-website] Bug Fix: Write l…
claude Oct 4, 2026
1227109
[lexical-mdast][lexical-website] Feature: Render GFM column alignment…
claude Oct 4, 2026
2264fc2
Chore: Clear mdast-editor footnotes with the document and style its b…
claude Oct 4, 2026
70a97ec
Merge branch 'main' into claude/mdast-tables-9323-twjf6u
etrepum Oct 4, 2026
e8032ff
Chore: Fix the mdast-editor build's type error in TableEditExtension
claude Oct 4, 2026
d0ab9a0
Chore: Type-check the dev examples in ci-check
claude Oct 4, 2026
bea4448
[lexical-mdast] Refactor: Parse table alignment with enumValue and ar…
claude Oct 4, 2026
2ed0212
[lexical-mdast] Refactor: Simplify MdastTableExtension's phrasing rew…
claude Oct 4, 2026
59fe3cb
[lexical-markdown][lexical-playground] Bug Fix: Table transformer edg…
claude Oct 4, 2026
c585107
[lexical-mdast][lexical-website] Feature: Write blocks in table cells…
claude Oct 4, 2026
c32b96b
[lexical-playground] Bug Fix: Keep literal <br> in table cells' code …
claude Oct 4, 2026
15c355e
Chore: Clear mdast-editor footnotes only when the body is deleted
claude Oct 4, 2026
5a60bee
[lexical-playground] Bug Fix: Read code blocks in table cells back
claude Oct 5, 2026
2f863b7
[lexical-mdast] Bug Fix: Move legacy table alignment onto the cells w…
claude Oct 5, 2026
673ef25
[lexical-playground] Bug Fix: Keep formatting across <br> in table ce…
claude Oct 5, 2026
7563819
[lexical-playground] Bug Fix: Keep code blocks and links whole in Mar…
claude Oct 5, 2026
117de91
[lexical-mdast] Bug Fix: Keep a cell's own format when migrating lega…
claude Oct 5, 2026
63aa527
Merge branch 'main' of https://github.com/facebook/lexical into claud…
claude Oct 5, 2026
5811e5b
[lexical-mdast] Chore: Use CodeExtension in the table HTML test
claude Oct 5, 2026
8087772
[lexical-mdast] Bug Fix: Keep the empty last line of a code block in …
claude Oct 5, 2026
e02539a
[lexical-playground] Bug Fix: Keep a backslash before a pipe in a Mar…
claude Oct 5, 2026
3fe72d5
[lexical-playground] Bug Fix: Read block-like lines, character refere…
claude Oct 5, 2026
0f03436
Merge branch 'main' of https://github.com/facebook/lexical into claud…
claude Oct 5, 2026
22b6257
[lexical-markdown] Bug Fix: Keep backslashes in a code span that is a…
claude Oct 5, 2026
d861d65
[lexical-playground] Bug Fix: Keep pipes and backslashes in inline co…
claude Oct 5, 2026
07cdfd7
[lexical-playground] Bug Fix: Escape a literal <code> tag in a Markdo…
claude Oct 5, 2026
3158558
[lexical-playground] Bug Fix: Keep code spacing, blocks after lists a…
claude Oct 5, 2026
80948a7
[lexical-playground] Bug Fix: Keep emphasis beside punctuation, wide …
claude Oct 5, 2026
e1c8c68
[lexical-markdown] Bug Fix: Read each backslash escape in formatted t…
claude Oct 6, 2026
7476bb4
[lexical-playground] Bug Fix: Keep line-break-only cells, Unicode spa…
claude Oct 6, 2026
deed0d0
[lexical-mdast][lexical-website] Bug Fix: Align a GFM column by most …
claude Oct 6, 2026
de0a059
[lexical-playground] Bug Fix: Check the table cell line break mark ag…
claude Oct 6, 2026
89d8ebb
[lexical-markdown] Bug Fix: Restore a cancelled line only when the tr…
claude Oct 6, 2026
91cc79c
[lexical-playground] Bug Fix: Keep empty rows, empty paragraphs and s…
claude Oct 6, 2026
58a4a3b
[lexical-mdast][lexical-website] Bug Fix: Align a GFM column by its h…
claude Oct 6, 2026
30971d3
[lexical-markdown] Chore: Add the inline code escape tests from upstr…
claude Oct 6, 2026
36a3f01
[lexical-mdast][lexical-website] Bug Fix: Read raw HTML in a cell as …
claude Oct 6, 2026
71095b1
[lexical-markdown] Chore: Keep only the escape tests that catch a reg…
claude Oct 6, 2026
e9670e4
[lexical-mdast][lexical-website] Bug Fix: Keep newlines in raw HTML t…
claude Oct 6, 2026
b2d83b3
Merge branch 'main' of https://github.com/facebook/lexical into claud…
claude Oct 6, 2026
994a300
[lexical-markdown] Bug Fix: Read escapes in text a text match edits i…
claude Oct 7, 2026
a5c65c4
[lexical-playground] Bug Fix: Keep table cell lines that read as tabl…
claude Oct 7, 2026
af74c87
[lexical-mdast] Bug Fix: Keep spaces at line edges in table cells and…
claude Oct 7, 2026
8356aae
Merge branch 'main' of https://github.com/facebook/lexical into claud…
claude Oct 7, 2026
eca8619
[lexical-markdown] Bug Fix: Read escapes in generated nodes, invalid …
claude Oct 7, 2026
555101b
[lexical-playground] Bug Fix: Read table cells without the table tran…
claude Oct 7, 2026
808fcee
[lexical-mdast] Bug Fix: Keep a line of spaces after a block in a cel…
claude Oct 7, 2026
afeed2f
[lexical-mdast] Bug Fix: Read cell spaces in a tree with only lines a…
claude Oct 7, 2026
6b7a0bc
[lexical-markdown] Bug Fix: Read character references as CommonMark d…
claude Oct 8, 2026
2278179
[lexical-playground] Bug Fix: Keep empty cell lines and pipe rows wit…
claude Oct 8, 2026
08ffa4c
[lexical-mdast] Bug Fix: Fill missing source offsets once per import
claude Oct 8, 2026
659f4ee
[lexical-mdast] Bug Fix: Skip frozen points and the offset walk when …
claude Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ For E2E testing workflow:
- `pnpm run prettier:fix` - Auto-fix formatting issues
- `pnpm run flow` - Run a full foreground Flow check (does not reuse a server)
- `pnpm run tsc` - Run TypeScript compiler
- `pnpm run tsc-dev-examples` - Type-check each `dev-examples/*` app with its own tsconfig (the root `tsc` doesn't cover them)
- `pnpm run ci-check` - Run all checks (TypeScript, Flow, Prettier, ESLint)

**Never commit changes to `scripts/error-codes/codes.json`.**
Expand Down
1 change: 1 addition & 0 deletions dev-examples/dom-import/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"tsc": "tsc",
"preview": "vite preview"
},
"dependencies": {
Expand Down
1 change: 1 addition & 0 deletions dev-examples/hmr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"tsc": "tsc",
"preview": "vite preview"
},
"dependencies": {
Expand Down
2 changes: 2 additions & 0 deletions dev-examples/mdast-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"tsc": "tsc",
"preview": "vite preview",
"size": "node ./size/measure.mjs"
},
Expand All @@ -24,6 +25,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",
Expand Down
26 changes: 26 additions & 0 deletions dev-examples/mdast-editor/src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 \`<br>\`:

| Construct | Syntax | Notes |
| :-- | :-: | --: |
| Strikethrough | \`~~text~~\` | GFM |
| Task list | \`- [ ] \` | GFM |
| Line break | \`<br>\` | One line<br>per paragraph |

## Alerts

GitHub-style alerts are plain blockquotes with a \`[!TYPE]\` marker: the
Expand Down Expand Up @@ -109,6 +122,10 @@ const editor = buildEditorFromExtensions({
`;

const theme = {
// The cursor Lexical draws where a text caret can't go, such as between
// two tables or decorators: a blinking horizontal bar.
blockCursor:
'pointer-events-none absolute block after:absolute after:-top-0.5 after:block after:w-5 after:border-0 after:border-t after:border-solid after:border-current after:content-[""] after:animate-[block-cursor-blink_1.1s_steps(2,start)_infinite]',
code: 'my-2 block rounded-md bg-zinc-100 p-3 font-mono text-sm whitespace-pre dark:bg-zinc-900',
heading: {
h1: 'mb-2 text-3xl font-bold',
Expand All @@ -132,6 +149,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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,21 @@ import {
} from '@lexical/mdast';
import {RichTextExtension} from '@lexical/rich-text';
import {
$createParagraphNode,
$createRangeSelection,
$getRoot,
$getSelection,
$getSlot,
$isElementNode,
$isRangeSelection,
$isTextNode,
$setSelection,
defineExtension,
HISTORY_MERGE_TAG,
INSERT_PARAGRAPH_COMMAND,
type LexicalEditor,
} from 'lexical';
import {describe, expect, onTestFinished, test, vi} from 'vitest';
import {assert, describe, expect, onTestFinished, test, vi} from 'vitest';

import {
INSERT_ALERT_COMMAND,
Expand All @@ -38,6 +42,7 @@ import {
} from '../../extensions/MdastCollapsibleExtension';
import {
$isFootnoteDefinitionNode,
FOOTNOTES_SLOT,
INSERT_FOOTNOTE_COMMAND,
MdastFootnoteExtension,
} from '../../extensions/MdastFootnoteExtension';
Expand Down Expand Up @@ -254,6 +259,53 @@ describe('footnotes', () => {
);
});

test('deleting everything else clears the footnotes', () => {
const {editor, root} = mountEditor('body[^a] text\n\n[^a]: the note');
editor.update(
() => {
$getRoot().clear().append($createParagraphNode()).selectEnd();
},
{discrete: true},
);
expect(root.querySelector('.footnote-def')).toBeNull();
expect(markdownOf(editor)).toBe('');
});

test('the footnotes stay while a definition is being edited', () => {
const {editor} = mountEditor('body[^a] text\n\n[^a]: the note');
editor.update(
() => {
const footnotes = $getSlot($getRoot(), FOOTNOTES_SLOT);
assert($isElementNode(footnotes));
footnotes.selectEnd();
$getRoot().clear().append($createParagraphNode());
},
{discrete: true},
);
// The empty body paragraph serializes as leading blank lines.
expect(markdownOf(editor).trim()).toBe('[^a]: the note');
});

test('a Markdown pane sync keeps definitions with no body', () => {
const {editor} = mountEditor('');
editor.update(() => $convertFromMarkdownString('[^a]: the note'), {
discrete: true,
tag: HISTORY_MERGE_TAG,
});
expect(markdownOf(editor)).toBe('[^a]: the note');
});

test('typing in an already-empty body keeps definitions', () => {
const {editor} = mountEditor('');
editor.update(() => $convertFromMarkdownString('[^a]: saved note'), {
discrete: true,
tag: HISTORY_MERGE_TAG,
});
editor.update(() => $getRoot().selectEnd(), {discrete: true});
editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
expect(markdownOf(editor).trim()).toBe('[^a]: saved note');
});

test('removing the last definition drops the section and its refs', async () => {
const {editor, root} = mountEditor(
'body[^a] and[^a] again\n\n[^a]: the note',
Expand Down
160 changes: 160 additions & 0 deletions dev-examples/mdast-editor/src/__tests__/browser/TableEdit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* 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', ''],
['align-center', '| a | b |\n| - | :-: |\n| 1 | 2 |'],
['align-right', '| a | b |\n| - | -: |\n| 1 | 2 |'],
])('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| | |');
});

test('an aligned column renders and clears its alignment', () => {
const {editor, root} = mountEditor('| a | b |\n| - | -: |\n| 1 | 2 |');
const cells = () =>
[...root.querySelectorAll('td, th')].map(
cell => (cell as HTMLElement).style.textAlign,
);
expect(cells()).toEqual(['', 'right', '', 'right']);
selectLastCell(editor);
editor.dispatchCommand(TABLE_EDIT_COMMAND, 'align-none');
// Reading commits the pending update to the DOM.
editor.read(() => {});
expect(cells()).toEqual(['', '', '', '']);
expect(exportMarkdown(editor)).toBe('| a | b |\n| - | - |\n| 1 | 2 |');
});

// https://github.com/facebook/lexical/issues/9323
test('Enter in a cell exports as <br>, 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<br>3 |',
);
});

test('a list and a code block in a cell round-trip as HTML', () => {
const markdown =
'| a |\n| - |\n| x<ul><li>**i**</li><li>j</li></ul><pre data-language="js">let y<br> z</pre> |';
const {editor} = mountEditor(markdown);
expect(
editor.read(() => {
let node: LexicalNode | null = $getRoot().getFirstChild();
while ($isElementNode(node) && !$isTableCellNode(node)) {
node = node.getLastChild();
}
return $isElementNode(node)
? node.getChildren().map(child => child.getType())
: [];
}),
).toEqual(['paragraph', 'list', 'code']);
expect(exportMarkdown(editor).split('\n')[2]).toMatch(
/^\| x<ul><li>\*\*i\*\*<\/li><li>j<\/li><\/ul><pre data-language="js">let y<br> {2}z<\/pre> +\|$/,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,16 @@ test.each(['', '\n\n[^a]: Footnote'])(
MdastEditorExtension,
).output;
await vi.waitFor(() => {
// Select-all covers the root's children; footnote definitions occupy a
// separate slot and remain outside that selection.
expect(markdown.value).toBe(footnotes);
// Select-all covers the root's children; the footnote definitions
// occupy a separate slot, which goes once nothing else is left.
expect(markdown.value).toBe('');
expect(toolbar.blockType.value).toBe('paragraph');
expect(toolbar.isBold.value).toBe(false);
expect(toolbar.isItalic.value).toBe(false);
expect(toolbar.isCode.value).toBe(false);
});

await userEvent.keyboard('replacement');
await vi.waitFor(() =>
expect(markdown.value).toBe('replacement' + footnotes),
);
await vi.waitFor(() => expect(markdown.value).toBe('replacement'));
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading