diff --git a/AGENTS.md b/AGENTS.md
index 94ae91fca06..ec0c5726548 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`.**
diff --git a/dev-examples/dom-import/package.json b/dev-examples/dom-import/package.json
index 1def708f84b..40ed0ad05f4 100644
--- a/dev-examples/dom-import/package.json
+++ b/dev-examples/dom-import/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
+ "tsc": "tsc",
"preview": "vite preview"
},
"dependencies": {
diff --git a/dev-examples/hmr/package.json b/dev-examples/hmr/package.json
index 38b949793fb..e7a519cef1a 100644
--- a/dev-examples/hmr/package.json
+++ b/dev-examples/hmr/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
+ "tsc": "tsc",
"preview": "vite preview"
},
"dependencies": {
diff --git a/dev-examples/mdast-editor/package.json b/dev-examples/mdast-editor/package.json
index 967b7243fd9..b5ba116325f 100644
--- a/dev-examples/mdast-editor/package.json
+++ b/dev-examples/mdast-editor/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
+ "tsc": "tsc",
"preview": "vite preview",
"size": "node ./size/measure.mjs"
},
@@ -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",
diff --git a/dev-examples/mdast-editor/src/Editor.tsx b/dev-examples/mdast-editor/src/Editor.tsx
index f59224725bf..6e32787a657 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
@@ -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',
@@ -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',
diff --git a/dev-examples/mdast-editor/src/__tests__/browser/MdastEditorChrome.test.ts b/dev-examples/mdast-editor/src/__tests__/browser/MdastEditorChrome.test.ts
index 83b406acadf..b93b268e59a 100644
--- a/dev-examples/mdast-editor/src/__tests__/browser/MdastEditorChrome.test.ts
+++ b/dev-examples/mdast-editor/src/__tests__/browser/MdastEditorChrome.test.ts
@@ -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,
@@ -38,6 +42,7 @@ import {
} from '../../extensions/MdastCollapsibleExtension';
import {
$isFootnoteDefinitionNode,
+ FOOTNOTES_SLOT,
INSERT_FOOTNOTE_COMMAND,
MdastFootnoteExtension,
} from '../../extensions/MdastFootnoteExtension';
@@ -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',
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..60547d9b1d0
--- /dev/null
+++ b/dev-examples/mdast-editor/src/__tests__/browser/TableEdit.test.ts
@@ -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
, 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 |',
+ );
+});
+
+test('a list and a code block in a cell round-trip as HTML', () => {
+ const markdown =
+ '| a |\n| - |\n| x
let y|'; + 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
z
let y
{2}z<\/pre> +\|$/, + ); +}); diff --git a/dev-examples/mdast-editor/src/__tests__/browser/ToolbarState.test.ts b/dev-examples/mdast-editor/src/__tests__/browser/ToolbarState.test.ts index 7a13f72ad2f..44642d8d0bd 100644 --- a/dev-examples/mdast-editor/src/__tests__/browser/ToolbarState.test.ts +++ b/dev-examples/mdast-editor/src/__tests__/browser/ToolbarState.test.ts @@ -103,9 +103,9 @@ 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); @@ -113,8 +113,6 @@ test.each(['', '\n\n[^a]: Footnote'])( }); await userEvent.keyboard('replacement'); - await vi.waitFor(() => - expect(markdown.value).toBe('replacement' + footnotes), - ); + await vi.waitFor(() => expect(markdown.value).toBe('replacement')); }, ); 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/MdastFootnoteExtension.ts b/dev-examples/mdast-editor/src/extensions/MdastFootnoteExtension.ts index 1eb22c2524d..51f1e0d4821 100644 --- a/dev-examples/mdast-editor/src/extensions/MdastFootnoteExtension.ts +++ b/dev-examples/mdast-editor/src/extensions/MdastFootnoteExtension.ts @@ -39,6 +39,7 @@ import { $create, $createParagraphNode, $getDocument, + $getEditor, $getNodeByKey, $getRoot, $getSelection, @@ -46,6 +47,7 @@ import { $getSlotHost, $getState, $getStateChange, + $hasUpdateTag, $isDecoratorNode, $isElementNode, $isParagraphNode, @@ -72,6 +74,7 @@ import { type ElementDOMSlot, ElementNode, HISTORIC_TAG, + HISTORY_MERGE_TAG, isExactShortcutMatch, isHTMLElement, KEY_DOWN_COMMAND, @@ -723,6 +726,44 @@ export function $clearFootnotes(): void { } } +/** Whether everything outside the footnotes section is empty paragraphs. */ +function $isBodyEmpty(root: RootNode): boolean { + return root + .getChildren() + .every(child => $isParagraphNode(child) && child.isEmpty()); +} + +/** + * A RootNode transform: once everything outside the footnotes section has + * been deleted (select-all and Backspace, cut, ...), the section goes too, + * since a definition with no reference to it doesn't render in GFM and the + * empty editor would otherwise still show stale notes. + * + * Left alone: a body that was already empty before this update (only a + * deletion clears, not Enter in an empty body), the Markdown pane's sync + * (tagged history-merge), so a note typed there before any body text isn't + * dropped mid-edit, and an editor whose caret is in a definition, which is + * being edited. + */ +function $clearFootnotesOfEmptyDocument(root: RootNode): void { + if ( + $hasUpdateTag(HISTORY_MERGE_TAG) || + !$isFootnotesNode($getSlot(root, FOOTNOTES_SLOT)) || + !$isBodyEmpty(root) || + $getEditor().read('latest', () => $isBodyEmpty($getRoot())) + ) { + return; + } + const selection = $getSelection(); + if ( + $isRangeSelection(selection) && + selection.anchor.getNode().getParents().some($isFootnotesNode) + ) { + return; + } + $clearFootnotes(); +} + /* -------------------------------------------------------------------------- * * Markdown import: refs inline, definitions relocated to the footnotes slot * * -------------------------------------------------------------------------- */ @@ -1155,6 +1196,7 @@ export const MdastFootnoteExtension = defineExtension({ mergeRegister( registerFootnoteShortcut(editor), registerFootnoteAnchors(editor), + editor.registerNodeTransform(RootNode, $clearFootnotesOfEmptyDocument), editor.registerCommand( KEY_DOWN_COMMAND, event => { 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..900c0593c1f --- /dev/null +++ b/dev-examples/mdast-editor/src/extensions/TableEditExtension.ts @@ -0,0 +1,176 @@ +/** + * 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, + $findCellNode, + $findTableNode, + $getTableColumnIndexFromTableCellNode, + $insertTableColumnAtSelection, + $insertTableRowAtSelection, + $isTableCellNode, + $isTableRowNode, + $isTableSelection, + TableExtension, + type TableNode, +} from '@lexical/table'; +import { + $createParagraphNode, + $getSelection, + $isElementNode, + $isRangeSelection, + COMMAND_PRIORITY_EDITOR, + configExtension, + createCommand, + defineExtension, + type ElementFormatType, + type LexicalCommand, +} from 'lexical'; + +export type TableEdit = + | 'row-above' + | 'row-below' + | 'column-left' + | 'column-right' + | 'delete-row' + | 'delete-column' + | 'delete-table' + | 'align-left' + | 'align-center' + | 'align-right' + | 'align-none'; + +const COLUMN_ALIGN = { + 'align-center': 'center', + 'align-left': 'left', + 'align-none': '', + 'align-right': 'right', +} as const; + +/** + * Aligns every column the selection touches. GFM alignment belongs to a + * column (the delimiter row's `:-:`), so it is set as the element format of + * each cell in the column, which `@lexical/mdast` renders as `text-align` + * and exports. The cells' paragraphs are reset so they follow the cell. + */ +function $alignSelectedColumns(table: TableNode, format: ElementFormatType) { + const selection = $getSelection(); + const cells = $isTableSelection(selection) + ? selection.getNodes().filter($isTableCellNode) + : [ + $isRangeSelection(selection) + ? $findCellNode(selection.anchor.getNode()) + : null, + ]; + const columns = new Set(); + for (const cell of cells) { + if (cell) { + columns.add($getTableColumnIndexFromTableCellNode(cell)); + } + } + for (const row of table.getChildren()) { + if (!$isTableRowNode(row)) { + continue; + } + const rowCells = row.getChildren(); + for (const column of columns) { + const cell = rowCells[column]; + if ($isTableCellNode(cell)) { + cell.setFormat(format); + for (const child of cell.getChildren()) { + if ($isElementNode(child) && !child.isInline()) { + child.setFormat(''); + } + } + } + } + } +} + +/** + * 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 'align-left': + case 'align-center': + case 'align-right': + case 'align-none': + $alignSelectedColumns(table, COLUMN_ALIGN[edit]); + 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..f1d3690aec0 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,23 @@ 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: 'Align column left', value: 'align-left'}, + {label: 'Align column center', value: 'align-center'}, + {label: 'Align column right', value: 'align-right'}, + {label: 'Clear column alignment', value: 'align-none'}, + {label: 'Delete table', value: 'delete-table'}, +]; + type InsertType = (typeof INSERT_TYPES)[number]['value']; function applyInsert(editor: LexicalEditor, type: InsertType): void { @@ -68,6 +88,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 +146,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 +232,28 @@ export function ToolbarPlugin() { ))} + {isInTable && ( + + )}